diff --git a/.github/release.md b/.github/release.md index 2f678f4..4726be1 100644 --- a/.github/release.md +++ b/.github/release.md @@ -29,10 +29,10 @@ shasum -a 256 -c checksums.txt | Build | Size | Syntax highlighting | |-------|------|---------------------| -| **full** (`exex-…--.tar.gz`) | larger | Chroma — full multi-language source + asm highlighting | +| **full** (`exex-…--.tar.gz`) | larger | Chroma — curated native/common source lexers + asm highlighting | | **lite** (`exex-…---lite.tar.gz`) | smaller | built-in minimal highlighter | -Everything else is identical, and both honour the same themes/colours. Exact archive sizes vary by platform and Go/dependency versions. Pick **lite** for the smaller binary, **full** for the richest colouring. +Everything else is identical, and both honour the same themes/colours. Source languages outside the curated Chroma set fall back to the built-in highlighter. Exact archive sizes vary by platform and Go/dependency versions. Pick **lite** for the smaller binary, **full** for the richest colouring. ### Usage diff --git a/.gitignore b/.gitignore index c275789..0e11593 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ exex .vscode dist -*.test \ No newline at end of file +*.test +perfreport + +# Regenerated by the golden-frame tests from internal/testbin; the snapshots in +# internal/ui/testdata/golden* are committed, this input is not. +internal/ui/testdata/tiny.elf diff --git a/README.md b/README.md index 3a4b916..10fd571 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # exex +A fast terminal UI for exploring **ELF, Mach-O and PE** binaries. exex shows the +file header, sections, segments, symbols, disassembly, hex/raw bytes, printable +strings, dynamic libraries, relocations, syscall sites and DWARF-driven source +mapping in one keyboard- and mouse-driven interface. A fast terminal UI for exploring **ELF, Mach-O and PE** binaries. exex shows the file header, sections, segments, symbols, disassembly, hex/raw bytes, printable strings, dynamic libraries, relocations, syscall sites and DWARF-driven source @@ -9,6 +13,9 @@ Its standout feature: when a binary has debug info (DWARF, or a Mach-O `.dSYM`), exex shows the **original source side by side with the exact disassembly it maps to**. It is interactive, navigable both ways, and entirely **static**: no debugger, no running process, no decompiler. +exex shows the **original source side by side with the exact disassembly it maps +to**. It is interactive, navigable both ways, and entirely **static**: no +debugger, no running process, no decompiler. ``` exex [-debug PATH] [-s STRING] [-o [VIEW]] [goto] @@ -17,6 +24,21 @@ exex [-debug PATH] [-s STRING] [-o [VIEW]] [goto] ## Highlights +- **One explorer for three formats:** ELF, Mach-O, PE and universal/fat Mach-O + slices. +- **Source ↔ disassembly:** original source beside machine code when DWARF or a + `.dSYM` is available. +- **Fast first look:** read-only, no project database, no debugger session. +- **Useful binary views:** symbols, sections, segments, strings, relocations, + libraries, syscall sites, CPU features, hex/raw bytes and disassembly. +- **Scriptable output:** `-o` emits plain text views for pipes and automation. +- **Text script mode:** readable shell/Python/etc. scripts open in a linked text + viewer instead of failing as “not a binary”. + +See [How exex compares to other tools](#how-exex-compares-to-other-tools) for the +tradeoffs against binutils, debuggers and RE platforms. +## Highlights + - **One explorer for three formats:** ELF, Mach-O, PE and universal/fat Mach-O slices. - **Source ↔ disassembly:** original source beside machine code when DWARF or a @@ -44,6 +66,8 @@ brew install shellcell/tap/exex Download the asset for your OS/arch from the [Releases](../../releases) page. Add `-lite` to the filename for the smaller build. +[Releases](../../releases) page. Add `-lite` to the filename for the smaller +build. ```sh # macOS / Linux @@ -55,10 +79,12 @@ sudo mv exex /usr/local/bin/ shasum -a 256 -c checksums.txt ``` +### Go install ### Go install ```sh go install github.com/rabarbra/exex@latest # full build +go install github.com/rabarbra/exex@latest # full build go install -tags lite github.com/rabarbra/exex@latest # lite build ``` @@ -75,6 +101,30 @@ make test-cross # cross-compile and parse/disassemble readable targets; needs G There are two builds. They are identical except for syntax highlighting: +| Build | Size | Syntax highlighting | +|-------|------|---------------------| +| **full** | larger | [Chroma](https://github.com/alecthomas/chroma) — curated native/common source lexers and assembly highlighting | +| **lite** | smaller | a small built-in highlighter (categorized source keywords/function names; categorized asm mnemonics plus registers / immediates / links) | + +The full build bundles a curated Chroma lexer/style set; source languages outside +that set fall back to the built-in highlighter. The lite build drops Chroma +entirely. Exact binary and archive sizes vary by platform and Go/dependency +versions, but lite is the smaller download. Both builds honour the same themes +and `colors:` config; the built-in highlighter follows your theme too. + +Pick **lite** if you want the smaller binary, **full** for the richest colouring. + +## Man Page and Shell Completions +make build # full -> ./exex +make lite # lite -> ./exex +make test # go test + lite vet +make test-cross # cross-compile and parse/disassemble readable targets; needs Go + Zig +``` + +## Full vs Lite Build + +There are two builds. They are identical except for syntax highlighting: + | Build | Size | Syntax highlighting | |-------|------|---------------------| | **full** | larger | [Chroma](https://github.com/alecthomas/chroma) — full multi-language source highlighting and assembly highlighting | @@ -231,6 +281,9 @@ scripts via `-o`. | `addr2line` | address → source `file:line` via DWARF | live in the source pane and Sources view — address ↔ source, both directions | | `size` | section/segment sizes | Info, Sections and Segments views | | `otool` (macOS) / `dumpbin` (Windows) | the Mach-O / PE counterparts of the above | a single tool across ELF, Mach-O and PE | +| `dyld_info` (macOS) | inspect a Mach-O's dyld metadata: bind/rebase & chained fixups, dependent dylibs, exports | Relocs view decodes both dyld bind/rebase opcodes and chained fixups into a neutral table (`-o relocs`); Libs view lists dependent dylibs, including the re-export/weak/upward variants | +| `dyld_shared_cache_util` (macOS) | list / extract dylibs from the dyld shared cache | opens a cache-resident system dylib straight from the Libs view (`o`), un-sharing it into a standalone, browsable Mach-O — no separate extraction step | +| `dyld_usage` (macOS) | live-trace a process's dyld / shared-cache activity | exex resolves the shared cache *statically*: it follows a macOS binary's imports through the cache (e.g. libSystem → libsystem_kernel) to surface the transitive syscall surface — `-o syscalls-full` — without running the program | In one line: those tools each answer one question, print, and exit; exex answers all of them in one place, lets you **navigate** between them (follow a call into @@ -277,6 +330,30 @@ it's the fast first look: open any ELF/Mach-O/PE, read its layout and code, foll references and source mappings interactively, and drop to plain text when you need to script. +## Architecture + +For contributors: [`docs/architecture.md`](docs/architecture.md) describes the +package layering (core `binfile`/`disasm`, domain services, the two frontends), +the TUI's view contract (`view.Context` / `view.Host`) and the rendering & +performance conventions, with diagrams. + +## Acknowledgements + +exex builds on the work of the Go toolchain and standard library authors, plus +the authors and maintainers of these projects: + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Bubbles](https://github.com/charmbracelet/bubbles) and [Lip Gloss](https://github.com/charmbracelet/lipgloss) for the terminal UI foundation. +- [Chroma](https://github.com/alecthomas/chroma) for full-build syntax highlighting. +- [`golang.org/x/arch`](https://pkg.go.dev/golang.org/x/arch) and [`golang.org/x/sys`](https://pkg.go.dev/golang.org/x/sys) for architecture decoders and system interfaces. +- [`github.com/ianlancetaylor/demangle`](https://pkg.go.dev/github.com/ianlancetaylor/demangle) for C++/Rust symbol demangling. +- [`github.com/atotto/clipboard`](https://github.com/atotto/clipboard) for clipboard integration. +- [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) for configuration parsing. + +See [`go.mod`](go.mod) for the full dependency list, including transitive +packages. + +ChatGPT was used as a development and documentation assistant. + ## Acknowledgements exex builds on the work of the Go toolchain and standard library authors, plus diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 277cdc6..1fc4b01 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -471,19 +471,20 @@ Extract all the pathes from strings 0x000106b6 should match with $0x106b6 -## 33. dyld shared cache resolution +## 33. dyld shared cache resolution ✅ (done) On macOS the system libraries (libsystem_kernel, libc++, the frameworks, the Swift runtime, …) are not standalone files — they live in the dyld shared cache (`/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/`, split into a main cache plus `.1/.2/…` sub-caches). Today that means: -- **Syscalls (#30):** `-o syscalls-full` can't scan the libraries that actually - contain the `svc` instructions (the app's own code makes no direct syscalls), - so macOS apps report nothing. The unresolved libraries are currently collapsed - into a single "in the dyld shared cache — can't be scanned" note. -- **Libs (#29):** cache-resident libraries are tagged `·cache` and can't be - opened. +- ✅ **Syscalls (#30):** `-o syscalls-full` now extracts the cache-resident + system libraries and follows the `LC_REEXPORT_DYLIB` chain + (app → libSystem.B → libsystem_kernel), giving macOS binaries a real syscall + surface (~460 distinct syscalls for a typical binary). +- ✅ **Libs (#29):** a `·cache` library opens as primary — extracted from the + shared cache with a compact per-image __LINKEDIT (all symbols, ~hundreds of + KB), fully browsable (sections/symbols/disasm). Add a reader for the dyld shared cache format — parse its header, mappings (each maps an address range to a file offset across the cache + sub-caches), and image @@ -491,9 +492,11 @@ list (address → install path) — so a cache-resident dylib can be extracted ( split segments stitched back via the mappings into a scannable Mach-O image). Then: -- resolve cache-resident lib paths for `syscalls-full` and scan their `svc` - sites (giving macOS a real syscall surface); -- let the Libs view open a `·cache` library as primary (its symbols/disasm). +Both delivered: `internal/dyldcache` (reader + `ExtractImage` un-sharer), +wired into `libopen.go` (open as primary) and `dump/syscalls.go` (transitive +cache-resident scan). Not attempted: reconstructing chained fixups for a cache +dylib (its relocs stay empty — the cache pre-applies them; see item #38 for the +on-disk Mach-O fixups decoder). ## 34. CPU-feature detection ✅ (done) @@ -540,3 +543,218 @@ Reduce duplication and the cache-invalidation bug surface in `internal/ui`: - **UX consistency pass**: a uniform address vocabulary (`synthetic` / `load` (LMA) / physical) across disasm/hex/sections/Info, and group the `?` help by the same role order as the footer hints. + +## 38. Mach-O dynamic fixups decoder (relocs) ✅ (done) + +The Relocations view was empty for essentially every real macOS binary: a linked +Mach-O carries its relocations not as the per-section relocs `debug/macho` parses +(those exist only in object files) but as dyld metadata, in one of two shapes — +`LC_DYLD_INFO(_ONLY)` compact bind/rebase opcode streams (the classic format, and +what the Go linker still emits) or `LC_DYLD_CHAINED_FIXUPS` in-place pointer +chains (the modern system-toolchain format, e.g. `/bin/ls`). + +`internal/binfile/macho_fixups.go` decodes both into the neutral `Reloc` model: +binds become named entries resolved to their imported symbol + library (the +useful part — the image's import table), rebases record the slid pointer slots; +arm64e authenticated pointers surface as `AUTH_BIND` / `AUTH_REBASE`. Wired into +the lazy `relocBuild` hook so it costs nothing until the relocs view/`-o relocs` +is opened, and `machoHasRelocs` reports true off a cheap load-command scan. +Validated byte-for-byte against the system `dyld_info` (exact bind/rebase counts +and addresses for `/bin/ls` chained and a Go binary's DYLD_INFO). Note: dylibs +un-shared from the dyld cache still show empty relocs — the cache already applied +the fixups and un-sharing drops the (now meaningless) fixup commands. + +Follow-ups landed with it: reloc bind targets are now demangled (Itanium/Rust +in-process, in both the TUI and `-o relocs`), and the relocs view gained the +shared row-navigation surface — `d`/`h`/`m` jump to the patched address in +disasm/hex/raw, `e` toggles argument abbreviation, double-click follows to hex, +and the text filter matches the demangled spelling. + +## 39. Performance / footprint review (plan) + +A whole-binary pass on size, startup, CPU and RAM — recording where things stand +and where the real (vs imagined) headroom is, so future work targets measured +costs, not guesses. Baseline (arm64, Go 1.26, this tree): + +- **Binary size** — 15.2 MB dev build; **11.5 MB stripped** (`-s -w`, the default + release) ; **9.9 MB lite** (drops Chroma). Composition: runtime ~4 MB, reflection + type metadata ~3.7 MB, `golang.org/x/arch` disassembler 1.25 MB, Chroma + + regexp2 ~0.8 MB (already `lite`-gated), `uax29` 0.32 MB (terminal width, via + `x/ansi`/`go-runewidth` — unavoidable), `yaml.v3` 0.32 MB (config), exex ~1 MB. +- **Startup** — ~1 ms warm (`ui.New` 455 KB alloc); parse (cold open) ~7 ms. +- **RAM** — retained-after-load heap **2.9 MB** (excellent); peak-heap-in-use + ~138 MB and peak RSS ~204 MB, but that is the perfreport *render/decode + benchmark* churning, not steady state. + +So the headline levers (strip, Chroma-gating) are **already done**. Genuine, +ranked opportunities remaining: + +1. **Render/decode allocation churn** — ✅ *investigated + first win landed.* + Profiling (memprofile of the disasm render bench) showed the render path is + already well-cached (asm-text cache, height cache, `viewCache`): the big + allocations — `decodeAcross` (~840 MB) and DWARF `loadLines` (~176 MB) — are + **one-time setup** the profile captures alongside the render, GC-reclaimed, not + steady-state churn (retained heap stays 2.9 MB). The one demonstrable waste: + `disasmInstVisualHeight` rendered the *full styled row* (`disasmInstRows`) just + to count its lines, on every height cache-miss (first paint, every resize/wrap + toggle, over the whole instruction list). Added `disasmInstRowCount` — the same + row-splitting decisions with none of the string building — pinned to the real + renderer by `TestDisasmInstRowCountMatches`. Measured: the height pass over 1024 + instructions dropped **5.53 ms → 0.30 ms, 1.42 MB → 45 KB, 23.9k → 2.8k allocs** + (~18×), and the warm disasm frame fell ~380 KB → ~339 KB. The remaining 138 MB + perfreport peak is the one-time `disasm-all` full-image decode (~162 MB) — the + disassembler stringifying every instruction once — which is inherent to that + view and not reduced here. +2. **Per-host-arch disasm build** — `x/arch` (1.25 MB) links every arch; an + opt-in single-arch tag (host only) would shave ~0.5–0.8 MB for distro builds. + Marginal, and complicates the matrix — low priority. +3. **`yaml.v3` → a smaller config decoder** (~0.3 MB). Config is user-facing, so + low ROI and some churn risk; only if a hand-rolled reader is otherwise wanted. +4. **Reflection type metadata (~3.7 MB)** is the largest reducible block but the + hardest — it tracks the reflect/encoding usage across deps; not worth chasing + without a specific offender identified by `-gcflags=-m`/deadcode analysis. + +Not a concern (measured, left alone): startup time, retained heap, `uax29`. + +## 40. Cross-view "open caret in…" modal ✅ (done) + +The per-view `d`/`h`/`m` jumps (go to the caret address in disasm/hex/raw) only +covered three destinations and had to be memorised. **Space** (or `>`) now opens a +single discoverable menu from any address-bearing view (disasm, hex, raw, +symbols, sections, strings, relocs): it takes the address under the cursor and +lists every *other* view as a destination. A header shows what the address *is* — +its covering symbol (demangled, with offset) and section, and, when the +pointer-sized word there is itself a mapped address (a GOT slot, a vtable entry), +where it points (`→ 0x… _malloc`). Each row previews its landing — the covering +function (Disasm), section + address (Hex), file offset (Raw), the symbol +(Symbols), the section (Sections), the quoted text of a string at that address +(Strings), or the relocation type + bound symbol (Relocs). Rows carry the target +view's number key as a badge, usable as a shortcut (press `5` → Hex); disabled +rows (e.g. Disasm on a data address, no string here) are dimmed with the reason. +Enter/click/digit navigates; the selection skips unreachable rows. The caret +carries a virtual address *and/or* a file offset, so an offset-only position (a +string in an unmapped section, a raw byte in a file header) still opens in Raw — +and in Strings, matched by offset — while the address-keyed targets dim with "no +virtual address"; the address views light up whenever the offset resolves to one. +The **Info** view has no cursor, so the modal opens on the binary's natural start: +its entry point, else the lowest mapped address. **Libs** and **Sources** are +deliberately excluded — their rows are a library/source path, not an address, and +each already has its full targeted-jump surface on Enter (imported symbols / +source pane) and `o` (open as primary). Shell-side (`internal/ui/jumpto.go`), +reusing the existing jump actions plus small +`CaretAddr`/`SelectByAddr`/`SelectByOffset`/`StringAt`/`StringAtOffset` accessors +on each view; the `d`/`h`/`m` shortcuts stay as fast paths. + +## 41. `f` global value search + modal polish ✅ (done) + +Three distinct navigations, now clearly separated: **goto (`g`)** is a *directory* +(jump to a named symbol/section/string/lib or a typed address); **open-in +(space)** takes the caret's *position* to another view; **`f`** searches the +binary's *content* for the *value* under the caret. goto can't do the third — it +only indexes named entities — so `f` has its own results engine. + +`f` opens a seed picker of the searchable things at the caret — the covering +**Symbol**, a **String**, the containing **Section**, the **Address**, and the +**Pointer** the bytes hold (read by address, or straight from the raw bytes at a +file offset, so a Raw caret over an unmapped header still works), plus the +**Library**/**Path** in the Libs/Sources views. Choosing one runs a **global +value search** across four sources, each a concurrent command (`tea.Batch`) whose +hits **stream** into one list as it finishes: **disasm** operand references +(reusing the parallel xref scanner — the slow one), **data** words holding the +address (pointer-width byte scan), **strings** containing the text, and **relocs** +targeting it. Matching is by *value*, not text, so the `0x` prefix is irrelevant; +disasm covers compact/RIP-relative x86 refs via the disassembler's resolution, +the data scan finds pointer-width absolute pointers. Results are tagged with the +view they belong to and **filterable by view** (a facet bar with per-source +counts, `⇥`/`⇧⇥` to cycle), with a `/` text filter and Enter-to-jump; a facet +still scanning shows "searching…", not "no occurrences". `c` in the picker copies +the seed's value. (`internal/ui/findsearch.go` + `findto.go`.) + +Modal polish landed across goto, the find results, and the syscalls modal: all +three now reserve a **fixed body height** (responsive to terminal resize, but no +vertical bounce as results stream in or the filter narrows), gained a **title↔tabs +gap** and consistent indentation, and **centre their empty/searching message** +horizontally and vertically. Goto results show a **view badge**; the syscalls +modal cycles scope on **`⇥`** (not just `t`). The find modal shows a live +**"● searching N sources"** indicator while the concurrent scans complete. + +Follow-ups: **`l`** opens a **free-text global search** — type a symbol, string, +or hex/decimal address and it runs the same content scan (a hex literal or a +resolved symbol name searches disasm/data/relocs; any text searches strings), so +you can search for something not under the caret. And an **address search now +surfaces the string that lives *at* the target address** (tagged "at target"), so +searching an address tells you what it is when it's a string. In the **Hex/Raw** +views the Pointer seed reads the **pointer-word-aligned** value the follow-pointer +action would use, so `f` mid-pointer matches `Enter`. + +The **`l`** free-text query splits cleanly: a `0x…` literal is an **address** +search (operand refs / pointer words / reloc targets / the string at it); anything +else is a **literal text/byte** search — instruction text (`disasm`), string +content (`strings`), and the raw file bytes (`data` = hex/raw) — no symbol +resolution, since an address is only ever a `0x…` value. + +**Perf/allocation review** (no perfreport regression: parse 6.8 ms / 9.2 MB, +disasm render ~331 KB, retained heap 2.92 MB, peak 138 MB — all stable). The +whole-image scan shared by xref / find / syscalls decoded every instruction into a +multi-hundred-MB slice just to filter it; a streamed decode (`DecodeRangeFunc`, +callback per instruction, no slice) cut the xref/find scan's allocation from +**457 MB → 134 MB (3.4×)** with identical results (guarded by +`TestDecodeRangeFuncMatchesSlice`). Per-instruction matching is allocation-free +(`ContainsFold` folds against a pre-lowercased needle); the data/strings/relocs +sources are near-zero alloc (the byte scan is 81 KB / 22 allocs). The residual +~134 MB / 8.9 M allocs is x/arch formatting each instruction's text — inherent to +decoding, and only reducible by structural operand matching (a disasm-package +change, left as a future item). The syscall direct scan still materialises its +slice (it needs a look-back window for number recovery); streaming it via a ring +buffer is a follow-up. + +**Case sensitivity + search-modal unification.** Text matching is now +**case-insensitive by default** with a per-search toggle. The `l` free-text search +toggles case with `^i` in its prompt; the in-view `/` search (disasm / hex / raw) +gained a **case** switch (click or `^i`) alongside mode/dir/origin. Caret-seeded +`f` searches stay **case-sensitive** — a seed is an exact value from the binary. +The byte/hex search folds ASCII case only for *text* patterns (a hex byte pattern +never folds); `bytesearch.FindBytesFold` + `IsTextPattern` back this. The in-view +search prompt was restyled to match the goto/find modals (title, gap, indented +input, switch strip, hint). Verified the change is behaviour-preserving: a +ground-truth scan of the Chrome Framework's x86_64 slice finds exactly the 3 +`0xbadbeef` instructions the default (case-insensitive) disasm search returns (the +arm64 slice has none — it builds the constant via `mov`/`movk`). + +## 42. Structured section decoder (idea) + +exex has the two ends of the spectrum — the **Hex** view (raw bytes) and the +**interpreted** views (Symbols, Relocs, Libs) — plus the **⇧H header modal**, +which already decodes the ELF header and Mach-O `mach_header` + load commands +field-by-field. The gap in the middle is showing *how* a table is encoded: a +**structured record view** that overlays a fixed-layout section with typed field +annotations (offset, raw bytes, decoded value) per record. + +**Symtab is the flagship example.** The Symbols view shows the *result*; a +structured view would show each `Elf64_Sym` / Mach-O `nlist_64` / COFF record: +`st_name` (string-table offset → resolved string), `st_info` (bind+type nibbles), +`st_other`, `st_shndx`, `st_value`, `st_size` — with the raw bytes each occupies. +It bridges Hex ↔ Symbols and teaches the on-disk format, which is squarely exex's +niche as a format explorer. + +**Scope: the well-defined fixed-record tables, not a generic "decode any +section" engine** (open-ended, and mostly redundant with the interpreted views). +Strong candidates, roughly in value order — tables exex does *not* yet decode +structurally: +- ELF **`.dynamic`** (`Elf_Dyn` tag/value pairs: NEEDED, RPATH, FLAGS, …) — genuinely + useful and currently unshown anywhere. +- ELF **`.note.*`** (`.note.gnu.build-id`, `.note.ABI-tag`, package metadata). +- ELF symbol **versioning** (`.gnu.version` / `.gnu.version_r` / `.gnu.version_d`). +- **symtab / nlist** records as an encoding view (complements the Symbols view). +- ELF program headers as records (the header modal covers the ELF header, not the + per-segment `Phdr` table). + +Lower priority (already interpreted elsewhere): relocation sections (Relocs view), +Mach-O load commands (header modal), PE import/export directories (Libs/Symbols). + +**Shape.** Either a Hex-view overlay mode (annotate the selected section's bytes +with field names/values, navigate record→record) or a dedicated table view driven +by a small per-section-type record-layout registry in `binfile`. Start with +`.dynamic` (highest signal, least redundant) + the symtab encoding view; extend to +notes/version as the registry grows. Medium effort; the decoders are small and +self-contained, mirroring `rawheader.go`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b4d84d8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,346 @@ +# Architecture + +exex is a terminal UI for exploring ELF, Mach-O and PE binaries. This document +describes how the packages relate, the layering rules that keep the codebase +navigable, and the contract that lets individual TUI views live in their own +packages. + +## Big picture + +```mermaid +graph TD + main["main.go
(CLI: flags, -o dispatch)"] + + subgraph presentation ["Presentation"] + ui["internal/ui
Bubble Tea shell: Model, dispatch,
chrome, keymap, mouse, search"] + dump["internal/dump
plain-text views for -o / pipes"] + views_relocs["internal/ui/views/relocs"] + views_sections["internal/ui/views/sections"] + views_symbols["internal/ui/views/symbols"] + views_strs["internal/ui/views/strs"] + views_libs["internal/ui/views/libs"] + views_more["…/sources · hexraw · info"] + views_sources["internal/ui/views/sources"] + views_info["internal/ui/views/info"] + views_hexraw["internal/ui/views/hexraw"] + viewpkg["internal/ui/view
Context + Host contract"] + layout["internal/ui/layout
text/scroll/list/table primitives"] + end + + subgraph services ["Domain services"] + explorer["internal/explorer
navigation + disasm service"] + bytesearch["internal/bytesearch"] + sourcefiles["internal/sourcefiles"] + syntax["internal/syntax
source highlighting"] + cpufeat["internal/cpufeat"] + syscalls["internal/syscalls"] + end + + subgraph corelayer ["Core"] + binfile["internal/binfile
ELF / Mach-O / PE / archives / DWARF"] + disasm["internal/disasm
uniform decoder over x/arch"] + arch["internal/arch"] + end + + subgraph support ["Support"] + config["internal/config"] + theme["internal/theme"] + chroma["internal/chromasubset
+ chromalexers + chromastyles
(curated, embedded XML)"] + end + + main --> ui + main --> dump + ui --> views_relocs + ui --> views_sections + ui --> views_symbols + ui --> views_strs + ui --> views_libs + ui --> views_sources + ui --> views_info + ui --> views_hexraw + views_relocs --> viewpkg + views_sections --> viewpkg + views_symbols --> viewpkg + views_strs --> viewpkg + views_libs --> viewpkg + views_sources --> viewpkg + views_info --> viewpkg + views_hexraw --> viewpkg + viewpkg --> layout + ui --> layout + ui --> explorer + ui --> syntax + dump --> binfile + explorer --> binfile + explorer --> disasm + syntax --> chroma + theme --> chroma + ui --> config + ui --> theme + binfile --> arch + disasm --> arch + ui -.-> bytesearch + ui -.-> sourcefiles + ui -.-> cpufeat + ui -.-> syscalls +``` + +Arrows point from the depender to the dependency. The intended flow is strictly +downward: presentation → services → core, with support packages usable from any +layer. Nothing below `internal/ui` imports it. + +## Layers + +### Core + +- **`internal/binfile`** is the heart: it opens a binary (ELF, Mach-O including + universal/fat slices and `.dSYM`, PE, `ar` archives) and exposes one neutral + `File` with sections, segments, symbols, relocations, imports, strings and + DWARF line/name lookups. Format differences are absorbed here; everything + above is format-agnostic. +- **`internal/disasm`** wraps `golang.org/x/arch` into a uniform decoder + (x86-64, ARM64, …) with an instruction classifier (`InstClass`) the UI uses + for colouring. +- **`internal/arch`** holds the shared CPU-architecture identifiers so binfile + and disasm agree without importing each other. + +### Domain services + +Format-neutral logic shared by both frontends: `explorer` (navigation targets, +the windowed disassembly service), `bytesearch` (query → byte patterns), +`sourcefiles` (ranking files referenced by DWARF), `syntax` (source +highlighting), `cpufeat` and `syscalls` (instruction/syscall classification +tables). + +### Presentation + +Two frontends share the same core: + +- **`internal/dump`** renders every view as plain text for `-o` / pipes. +- **`internal/ui`** is the interactive Bubble Tea TUI. + +### Support + +`config` (YAML user config), `theme` (palettes extracted from Chroma styles), +and the curated Chroma subset: `chromasubset` owns the embedded XML lexer/style +assets, `chromalexers`/`chromastyles` expose lazily-built registries. Curating +the subset (instead of importing all of Chroma) keeps the stripped binary about +2 MB smaller; a registry test (`TestUsingGraphIsClosed`) guarantees every +`` reference stays resolvable within the subset. + +## Inside internal/ui + +The TUI follows the Elm architecture (Bubble Tea): one `Model`, `Update`, +`View`. The package is large, so it is being decomposed along three seams: + +```mermaid +graph TD + Model["Model (model.go)
per-view *State structs"] + views_go["modeView interface (views.go)
one adapter per mode"] + ctx["view.Context
render inputs snapshot"] + host["view.Host
mutating actions"] + relocs["views/relocs.State"] + sections["views/sections.State"] + symbols["views/symbols.State"] + strs["views/strs.State"] + libspkg["views/libs.State"] + sourcespkg["views/sources.State"] + infopkg["views/info.State"] + hexrawpkg["views/hexraw.State"] + layout2["layout
pure primitives"] + + Model -->|"viewFor(mode)"| views_go + views_go -->|"builds"| ctx + views_go -->|"Model satisfies"| host + views_go --> relocs + views_go --> sections + views_go --> symbols + views_go --> strs + views_go --> libspkg + views_go --> sourcespkg + views_go --> infopkg + views_go --> hexrawpkg + relocs --> ctx + relocs --> host + sections --> ctx + sections --> host + symbols --> ctx + symbols --> host + strs --> ctx + strs --> host + libspkg --> ctx + libspkg --> host + sourcespkg --> ctx + sourcespkg --> host + infopkg --> ctx + infopkg --> host + hexrawpkg --> ctx + hexrawpkg --> host + ctx --> layout2 + relocs --> layout2 + sections --> layout2 + symbols --> layout2 + strs --> layout2 + libspkg --> layout2 + sourcespkg --> layout2 + infopkg --> layout2 + hexrawpkg --> layout2 +``` + +### 1. `internal/ui/layout` — pure primitives + +Model-independent building blocks: ANSI-aware padding/truncation/wrapping, +scroll anchoring (`VisualTop`, `ViewportTop`, `PageStep`), list navigation +(`NavKey`), filtering (`ContainsFold`), facet cycling, generic sortable-header +hit-testing (`SortableHeaderCol[T]`, `SortHeaderLabel`), overlays, a bounded +per-row render memo (`RowMemo[K,V]`), and the collapsible name tree shared by +the symbols/sources/libs views (`TreeNode`/`TreeRow`, `BuildScopedTree`, +`FlattenTree`, fold helpers). No imports from the rest of the UI, so it is +trivially testable and reusable. + +### 2. `internal/ui/view` — the view contract + +The neutral seam between the shell and the individual views, so a view package +never imports `ui` (which would be an import cycle): + +- **`view.Context`** is a per-frame snapshot of render inputs: the `*binfile.File`, + geometry (`Width`, `BodyH`) and global toggles (`Wrap`, `Detached`, + `TreeCollapseDefault`), plus shared presentation helpers (`TableHeader`, + `EmptyBody`, `EmptyList`, `VisualTop`, `TreeNodeRow`). Context is passed by + value through view helpers — often per visible row — so it must stay a few + machine words; `TestContextStaysSmall` enforces a 64-byte budget. The style + vocabulary hangs off the embedded **`*view.Styles`**: ~20 exported + `lipgloss.Style` fields (the shell's full `Theme` stays private), the + theme-derived classifier closures (`SectionStyle`, `SegmentStyle`, + `SymbolStyle`, `PathStyle`, `SymbolDisplay`, …), and settings-modal-owned + display knobs (`HexBytesPerRow`, `HideAnnotations`). The shell builds one + Styles lazily and caches it until a theme/settings change + (`m.viewStylesCache`); the closures read `m` at call time so they never go + stale. (Closures, not method values — a method value on the large `Theme` + struct would copy it to the heap per call. And styles on Styles, not + Context: inlining them made Context ~13 KB and hex full-frame renders ~60% + slower from per-row copies.) +- **`view.Host`** is the small interface of mutating actions a view may trigger + on the shell: `SetStatus`, cross-view jumps (`JumpHexAtAddr`, + `JumpDisasmAtAddr`, `JumpRawAtAddr`, `OpenHexAt`, `OpenRawAt`, + `OpenSymbol`, `OpenSymbolsForLib`, `OpenSourceFile`), cache invalidation + signals (`SymbolNamesChanged`), `CopyToClipboard`, `ToggleWrap`, and paging + (`ListPage`/`SetPageRows`). + `*Model` satisfies it via thin exported wrappers in `viewcontext.go`. +- **`view.RowCacheKey`** is the shared memo key (item index + every layout input + that changes how a row renders). + +### 3. `internal/ui/views/*` — self-contained views + +Views converted to the contract live in their own packages, holding their own +`State` (cursor, filter, sort, facets, row caches) with methods shaped +`func (st *State) X(ctx view.Context, host view.Host, …)`: + +- **`views/relocs`** — the pilot: the relocation table (filter, facets, sort, + header clicks, rendering) with zero knowledge of the shell. +- **`views/sections`** — sections/segments tables, including the `t` mode + toggle, type/flags facets and the sortable header. +- **`views/symbols`** — the largest view: flat table + collapsible namespace + tree, kind/scope/bind facets, clickable facet chips, per-row/global + argument abbreviation (`AbbrevBrackets` is exported — the shell's + `displaySymbolName` applies it to disasm/hex annotations too), and the + demangle interplay (`OnNamesChanged` invalidates name-derived state). +- **`views/strs`** — the Strings view: filterable table + the compact + "·"-separated flow layout with its 2-D packing/navigation geometry, the + section/paths-only facets, and the path/URL heuristic. (Named `strs`, not + `strings`, to avoid colliding with the standard library.) +- **`views/libs`** — the Libraries view: DT_NEEDED entries as a flat list or + path tree with the linkage-context header (interpreter, libc, RPATH), name + filter and the on-disk/in-cache availability lens (it classifies via + `explorer.ResolveLibPath` and caches per path). Opening a library as the + primary file replaces the whole `Model`, which is beyond the `view.Host` + surface — the shell's key adapter intercepts that one key ("o"). +- **`views/sources`** — the Sources file list (DWARF): project-first flat list + or directory tree with the present/missing availability lens. Opening a file + switches to the disasm split pane, which stays in the shell + (`view_sources.go`) because it drives the disasm window and cursor. +- **`views/hexraw`** — one package for both byte views (Hex is + virtual-address-based, Raw file-offset-based) sharing a `ByteSource` + abstraction: rendering, cursor/scroll geometry, section pins/seeking, the + data inspector, word interpretations and pointer following. Its render path + takes `*view.Context` (per-byte hot path). +- **`views/info`** — the Info overview page (header table, overview, + hardening, linking and toolchain blocks) with its styled-body cache. + Archive-member browsing and fat-arch switching stay in the shell — both + replace the whole `Model`. +- **`views/sources`** — the Sources file list: DWARF-referenced source files as + a project-first flat list or path tree, with name filtering and an on-disk + availability lens. Opening a file switches to the shell-owned disasm + source-first split pane via `view.Host`. +- **`views/info`** — the normal Info overview page: a cached, scrollable header / + overview / hardening / linking summary. Archive member browsing and fat-arch + switching stay in the shell because they replace the whole `Model`. +- **`views/hexraw`** — the shared Hex/Raw byte-dump views: virtual-address image + rendering, raw-file rendering, byte cursors/tops, section pinning, pointer + decoding/following, byte-row geometry and mouse hit-testing. Shell glue for + cross-view address/offset opens stays in `byteopen.go`. + +The shell keeps one field per converted view on `Model` (`m.relocs`, +`m.sections`, `m.symbols`, `m.strs`, `m.libs`, `m.sources`, `m.info`, +`m.byteViews`) and the thin `modeView` adapter in `views.go` forwards +`body`/`handleKey`/`sortHeaderClick`/`captureFilter` into the package with +`(m.viewContext(), m)`. Cross-view routing that needs shell internals stays in +the shell: `openSymbol`/`canDisasmAt`/`displaySymbolName` live in +`symbolopen.go`, the `-s` flag's `openStringSearch` (which switches modes) in +`stringsearch.go`, byte-view address/offset opens in `byteopen.go`, the Libs +view's model-swapping `openLibAsPrimary` + mode-switching `openSymbolsForLib` in +`libopen.go`, and the Info view's fat-arch/archive-member model swaps in +`infoopen.go` + `archive.go`; views reach such actions via `view.Host`. + +Views not yet converted (disasm) still live as `Model` methods in `view_*.go` +files with their state embedded in `Model`; the `modeView` interface already +centralises dispatch, so it can be migrated along the same recipe: + +1. Change the view's methods to hang off its `*State` and take + `(ctx view.Context, host view.Host)`; add any missing action to `view.Host` + or style to `view.Context`. +2. Move the state struct + methods into `internal/ui/views/`, exporting + the fields the shell/tests touch. +3. Replace the embedded state with a named `Model` field and rewire the + adapter, `new.go` construction, mouse geometry and the central filter + capture. + +### Shell responsibilities that stay in `ui` + +Cross-view concerns remain in the shell: mode switching and history (`nav.go`, +`jump.go`), the frame chrome (header/tabs/footer/status in `chrome.go`, +`render.go`), key normalisation and dispatch (`keymap.go`, `key_dispatch.go`), +mouse routing and wheel momentum (`mouse.go`), the goto/search/settings/xref +overlays, background work (async disasm decode, demangling), clipboard, and +cross-file navigation (`crossfile.go`, `archive.go`). + +## Rendering & performance conventions + +- **Row memoisation**: table views render a row once per + `(index, width, addrW, wrap)` via `layout.RowMemo` and reuse it until a cache + is invalidated (filter/sort/theme changes). Height caches serve the scroll + math without re-rendering. +- **Frame cache**: `Model.View()` re-renders only when `viewDirty`; wheel input + is coalesced and applied on a tick. +- **Windowed disassembly**: disasm decodes a bounded window around the target + address (first window in the background), never the whole image. +- **Lazy everything**: strings/sources/symbols views build their lists on first + entry; Chroma lexers/styles are parsed from embedded XML on first use + (`sync.OnceValue`). +- **`tools/perfreport`** measures parse, per-view render time/allocations, + peak live heap and peak RSS against a sample binary (CI feeds it the freshly + built exex). **Peak heap in use** (a 1 ms `runtime/metrics` sampler) is the + number to compare across branches; **peak resident memory** also counts + arena over-reservation and not-yet-returned pages, which on macOS swings + ±40 MB between runs with byte-identical heap behaviour. + `internal/ui/render_bench_test.go` has `-benchmem` benchmarks + (`EXEX_BENCH_BIN= go test ./internal/ui -bench View -benchmem`) for + steady-state per-frame allocation checks. One-shot alloc numbers in + perfreport's TUI rows are noisy (GC timing); trust the benchmarks for + regression calls. + +## Build variants + +- Default build embeds the curated Chroma subset for syntax highlighting. +- `-tags lite` drops Chroma entirely (`disasm_syntax_lite.go`) for a smaller + binary; the UI falls back to theme-only colouring. diff --git a/internal/binfile/demangle_test.go b/internal/binfile/demangle_test.go index 739a238..f34637b 100644 --- a/internal/binfile/demangle_test.go +++ b/internal/binfile/demangle_test.go @@ -31,3 +31,20 @@ func TestDemangleName(t *testing.T) { } } } + +func TestDemangleWorkers(t *testing.T) { + for _, tc := range []struct { + procs int + want int + }{ + {0, 1}, + {1, 1}, + {4, 4}, + {8, 8}, + {16, 8}, + } { + if got := demangleWorkers(tc.procs); got != tc.want { + t.Errorf("demangleWorkers(%d) = %d, want %d", tc.procs, got, tc.want) + } + } +} diff --git a/internal/binfile/elf.go b/internal/binfile/elf.go index cb34a6f..c869e01 100644 --- a/internal/binfile/elf.go +++ b/internal/binfile/elf.go @@ -192,11 +192,13 @@ func (f *File) loadELF() error { staticSyms, _ := ef.Symbols() dynSyms, _ := ef.DynamicSymbols() + symCount := len(staticSyms) + len(dynSyms) type symKey struct { name string addr uint64 } - seen := map[symKey]bool{} + seen := make(map[symKey]bool, symCount) + f.Symbols = make([]Symbol, 0, symCount) add := func(s elf.Symbol) { if s.Name == "" || isELFMappingSymbol(s.Name) || isELFLocalLabel(s) { return @@ -233,7 +235,7 @@ func (f *File) loadELF() error { for _, s := range dynSyms { add(s) } - f.appendELFImportSymbols(ef) + f.appendELFImportSymbols(ef, dynSyms) if f.elfHasDWARF(ef) { f.dwarfAvail = true @@ -267,9 +269,8 @@ func decodeReloc(data []byte, off int, is64 bool, bo binary.ByteOrder) (roff uin // slot (and, by pairing .rela.plt with .plt, each PLT stub) is named after the // imported dynamic symbol it binds to. This resolves calls/jumps to imported // functions in the disassembly for x86-64 and 386. -func (f *File) appendELFImportSymbols(ef *elf.File) { - dyn, err := ef.DynamicSymbols() - if err != nil || len(dyn) == 0 { +func (f *File) appendELFImportSymbols(ef *elf.File, dyn []elf.Symbol) { + if len(dyn) == 0 { return } is64 := ef.Class == elf.ELFCLASS64 diff --git a/internal/binfile/image.go b/internal/binfile/image.go index c0b3818..5b0c53e 100644 --- a/internal/binfile/image.go +++ b/internal/binfile/image.go @@ -97,7 +97,10 @@ func (im *Image) At(pos int) byte { // lies within a single region (the common case); a range straddling a region // boundary is copied into a fresh bounded buffer. func (im *Image) Bytes(start, end int) []byte { - if im == nil || start < 0 { + if im == nil { + return nil + } + if start < 0 { start = 0 } if end > im.size { diff --git a/internal/binfile/loader.go b/internal/binfile/loader.go index bb82ef3..3f69afa 100644 --- a/internal/binfile/loader.go +++ b/internal/binfile/loader.go @@ -52,7 +52,6 @@ func Open(path string, opts ...Option) (*File, error) { layoutOnly: o.layoutOnly, raw: raw, unmap: closer, - sources: map[string][]string{}, } if err := f.load(); err != nil { f.Close() @@ -67,9 +66,8 @@ func Open(path string, opts ...Option) (*File, error) { // alive for the lifetime of the returned File (its sections slice into it). func OpenBytes(name string, raw []byte) (*File, error) { f := &File{ - Path: name, - raw: raw, - sources: map[string][]string{}, + Path: name, + raw: raw, } if err := f.load(); err != nil { return nil, err diff --git a/internal/binfile/macho.go b/internal/binfile/macho.go index 0383662..c4a684d 100644 --- a/internal/binfile/macho.go +++ b/internal/binfile/macho.go @@ -37,6 +37,9 @@ const ( const ( lcMain = 0x80000028 lcLoadDylinker = 0x0e + lcLoadWeakDylib = 0x80000018 // LC_LOAD_WEAK_DYLIB + lcReexportDylib = 0x8000001f // LC_REEXPORT_DYLIB + lcLoadUpwardDylib = 0x80000023 // LC_LOAD_UPWARD_DYLIB lcUUID = 0x1b lcCodeSignature = 0x1d lcEncryptionInfo = 0x21 @@ -260,7 +263,7 @@ func (f *File) loadMachO() error { } f.Symbols = append(f.Symbols, machoImportSymbols(mf, f.raw, base, libs)...) - f.entry = machoEntry(mf, textSeg, base) + f.entry = machoEntry(mf, textSeg) f.loadMachOInfo(mf) // reads mf.Symtab (Stripped), so before it is dropped below f.dwarfAvail = f.machoHasDWARF(mf) f.header = f.machoHeaderInfo(mf) @@ -275,7 +278,14 @@ func (f *File) loadMachO() error { symNames[i] = mf.Symtab.Syms[i].Name } } - f.relocBuild = func() []Reloc { return machoRelocs(mf, base, symNames) } + raw := f.raw + f.relocBuild = func() []Reloc { + out := machoRelocs(mf, base, symNames) + // A linked image carries its relocations as dyld bind/rebase or chained + // fixups rather than per-section relocs; decode those too so the view is + // populated for real executables and dylibs, not just object files. + return append(out, machoDynamicFixups(mf, raw, base)...) + } } // Defer the DWARF decode (abbrev/section parse — a big slice of Open for debug @@ -611,7 +621,7 @@ func machoHasRelocs(mf *macho.File) bool { return true } } - return false + return machoHasDynamicFixups(mf) } // machoHasDWARF reports whether DWARF is available without parsing it: an @@ -953,7 +963,7 @@ func neutralFlags(alloc, write, exec bool) string { // machoEntry resolves the entry point. LC_MAIN records an offset from the start // of __TEXT; we add the segment's virtual base to recover the address. -func machoEntry(mf *macho.File, textSeg *macho.Segment, base uint64) uint64 { +func machoEntry(mf *macho.File, textSeg *macho.Segment) uint64 { for _, l := range mf.Loads { lb, ok := l.(macho.LoadBytes) if !ok { @@ -995,6 +1005,59 @@ func machoEntry(mf *macho.File, textSeg *macho.Segment, base uint64) uint64 { return 0 } +// machoAllDylibs returns every library the image links against, in load order: +// regular LC_LOAD_DYLIB (which the stdlib parses into *macho.Dylib) plus the +// weak, re-export and upward variants (raw load commands the stdlib leaves as +// LoadBytes). The stdlib's ImportedLibraries() sees only the first kind, so an +// umbrella like libSystem.B — which pulls in libsystem_kernel via +// LC_REEXPORT_DYLIB — would otherwise look dependency-less. +func machoAllDylibs(mf *macho.File) []string { + var libs []string + seen := map[string]bool{} + add := func(name string) { + if name != "" && !seen[name] { + seen[name] = true + libs = append(libs, name) + } + } + for _, l := range mf.Loads { + switch v := l.(type) { + case *macho.Dylib: + add(v.Name) + case macho.LoadBytes: + raw := v.Raw() + if len(raw) < 16 { + continue + } + switch mf.ByteOrder.Uint32(raw) { + case lcLoadWeakDylib, lcReexportDylib, lcLoadUpwardDylib: + nameOff := mf.ByteOrder.Uint32(raw[8:]) + if int(nameOff) < len(raw) { + add(cStr(raw[nameOff:])) + } + } + } + } + return libs +} + +// cStr reads a NUL-terminated string from the head of b. +func cStr(b []byte) string { + if i := indexNUL(b); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +func indexNUL(b []byte) int { + for i, c := range b { + if c == 0 { + return i + } + } + return -1 +} + func mfSyms(mf *macho.File) []macho.Symbol { if mf.Symtab == nil { return nil @@ -1004,9 +1067,7 @@ func mfSyms(mf *macho.File) []macho.Symbol { func (f *File) loadMachOInfo(mf *macho.File) { in := &Info{} - if libs, err := mf.ImportedLibraries(); err == nil { - in.DynamicLibs = libs - } + in.DynamicLibs = machoAllDylibs(mf) in.BuildID = machoUUID(mf) in.Stripped = mf.Symtab == nil || len(mf.Symtab.Syms) == 0 in.StaticLinked = len(in.DynamicLibs) == 0 diff --git a/internal/binfile/macho_fixups.go b/internal/binfile/macho_fixups.go new file mode 100644 index 0000000..af0186c --- /dev/null +++ b/internal/binfile/macho_fixups.go @@ -0,0 +1,696 @@ +package binfile + +// Dynamic relocations of a *linked* Mach-O image. The standard library only +// exposes the per-section relocations of object files (machoRelocs); a finished +// executable or dylib instead carries its fixups as dyld metadata, in one of two +// shapes the loader applies at launch: +// +// - LC_DYLD_INFO(_ONLY): compact opcode streams (rebase + bind/weak/lazy bind) +// — the classic format, still emitted by many toolchains (incl. the Go +// linker), and +// - LC_DYLD_CHAINED_FIXUPS: a walk of in-place pointer chains, each pointer's +// spare bits pointing at the next — the modern format used by the system +// toolchain (e.g. /bin/ls) and the dyld shared cache dylibs. +// +// Neither is decoded by debug/macho, so without this the Relocations view is +// empty for essentially every real macOS binary ("this Mach-O uses dyld chained +// fixups, which aren't decoded yet"). We decode both into the neutral Reloc +// model: binds become named entries pointing at the imported symbol (the useful +// part — the import table of the image), rebases record the slid pointer slots. + +import ( + "debug/macho" + "encoding/binary" + "sort" +) + +// Load commands that carry dynamic fixups (debug/macho leaves them as raw +// LoadBytes). +const ( + lcDyldInfo = 0x22 + lcDyldInfoOnly = 0x80000022 + lcDyldChainedFixups = 0x80000034 +) + +// maxDynamicFixups bounds the neutral list built from a linked image. Rebases in +// particular can number in the hundreds of thousands (every absolute pointer in +// a large Go binary is one); past this we stop, since the view/dump is already +// well beyond useful and each entry costs memory. +const maxDynamicFixups = 200000 + +// machoHasDynamicFixups reports, cheaply, whether the image carries dyld +// bind/rebase or chained-fixup load commands — used so HasRelocs/relocAvail is +// true for a linked Mach-O without forcing the (potentially large) decode. +func machoHasDynamicFixups(mf *macho.File) bool { + for _, l := range mf.Loads { + lb, ok := l.(macho.LoadBytes) + if !ok { + continue + } + raw := lb.Raw() + if len(raw) < 8 { + continue + } + switch mf.ByteOrder.Uint32(raw) { + case lcDyldInfo, lcDyldInfoOnly, lcDyldChainedFixups: + return true + } + } + return false +} + +// machoDynamicFixups decodes the image's dyld fixups into neutral Reloc entries. +// raw is the whole file image; base is the chosen slice's file offset (fixup +// tables reference file offsets relative to that slice). Addresses in the result +// are virtual addresses, matching the disasm/symbol/hex views. +func machoDynamicFixups(mf *macho.File, raw []byte, base uint64) []Reloc { + segs := machoSegmentsInOrder(mf) + dylibs := machoDylibOrdinals(mf) + secAt := machoSectionLocator(mf) + is64 := mf.Magic == macho.Magic64 + bo := mf.ByteOrder + + var out []Reloc + for _, l := range mf.Loads { + if len(out) >= maxDynamicFixups { + break + } + lb, ok := l.(macho.LoadBytes) + if !ok { + continue + } + cmd := lb.Raw() + if len(cmd) < 8 { + continue + } + switch bo.Uint32(cmd) { + case lcDyldInfo, lcDyldInfoOnly: + out = machoDyldInfoFixups(out, cmd, raw, base, bo, is64, segs, dylibs, secAt) + case lcDyldChainedFixups: + out = machoChainedFixups(out, cmd, raw, base, bo, segs, dylibs, secAt) + } + } + return out +} + +// machoSegmentsInOrder returns the image's segments in load-command order, which +// is the order both the dyld bind opcodes (SET_SEGMENT_AND_OFFSET) and the +// chained-fixups per-segment table index by. +func machoSegmentsInOrder(mf *macho.File) []*macho.Segment { + var segs []*macho.Segment + for _, l := range mf.Loads { + if seg, ok := l.(*macho.Segment); ok { + segs = append(segs, seg) + } + } + return segs +} + +// machoDylibOrdinals returns dylib names indexed for ordinal lookup: entry i is +// the (i+1)-th dylib load command (LC_LOAD_DYLIB and its weak/reexport/upward +// variants), matching the 1-based ordinal stored in bind entries. Unlike +// machoAllDylibs this keeps every command in order without de-duplicating, so the +// ordinal→name mapping stays exact. +func machoDylibOrdinals(mf *macho.File) []string { + var libs []string + for _, l := range mf.Loads { + switch v := l.(type) { + case *macho.Dylib: + libs = append(libs, v.Name) + case macho.LoadBytes: + raw := v.Raw() + if len(raw) < 16 { + continue + } + switch mf.ByteOrder.Uint32(raw) { + case lcLoadWeakDylib, lcReexportDylib, lcLoadUpwardDylib: + nameOff := mf.ByteOrder.Uint32(raw[8:]) + if int(nameOff) < len(raw) { + libs = append(libs, cStr(raw[nameOff:])) + } + } + } + } + return libs +} + +// dylibName maps a 1-based bind ordinal to its library name. Non-positive +// "special" ordinals (self / main-executable / flat-lookup) name no concrete +// library and yield "". +func dylibName(ordinal int, dylibs []string) string { + if ordinal >= 1 && ordinal <= len(dylibs) { + return dylibs[ordinal-1] + } + return "" +} + +// machoSectionLocator returns a function mapping a virtual address to the +// "SEG,sect" label of the section containing it (matching machoRelocs' Section +// field), or "" when no section covers it. Backed by an address-sorted index so +// the per-fixup lookup stays cheap across a large chain. +func machoSectionLocator(mf *macho.File) func(uint64) string { + type secSpan struct { + lo, hi uint64 + label string + } + spans := make([]secSpan, 0, len(mf.Sections)) + for _, s := range mf.Sections { + if s.Size == 0 { + continue + } + spans = append(spans, secSpan{lo: s.Addr, hi: s.Addr + s.Size, label: s.Seg + "," + s.Name}) + } + sort.Slice(spans, func(i, j int) bool { return spans[i].lo < spans[j].lo }) + return func(addr uint64) string { + i := sort.Search(len(spans), func(i int) bool { return spans[i].hi > addr }) + if i < len(spans) && addr >= spans[i].lo { + return spans[i].label + } + return "" + } +} + +// slab returns raw[off : off+size] with bounds checking, or nil. +func slab(raw []byte, off, size uint64) []byte { + if off > uint64(len(raw)) || off+size > uint64(len(raw)) || off+size < off { + return nil + } + return raw[off : off+size] +} + +// --- LC_DYLD_INFO opcode streams -------------------------------------------- + +// dyld bind opcodes (mach-o/loader.h). opcode = byte & 0xf0, immediate = & 0x0f. +const ( + bindDone = 0x00 + bindSetDylibOrdImm = 0x10 + bindSetDylibOrdUleb = 0x20 + bindSetDylibSpecial = 0x30 + bindSetSymbol = 0x40 + bindSetType = 0x50 + bindSetAddend = 0x60 + bindSetSegOff = 0x70 + bindAddAddrUleb = 0x80 + bindDoBind = 0x90 + bindDoBindAddUleb = 0xa0 + bindDoBindImmScaled = 0xb0 + bindDoBindUlebSkip = 0xc0 + bindThreaded = 0xd0 +) + +// dyld rebase opcodes. +const ( + rebaseDone = 0x00 + rebaseSetType = 0x10 + rebaseSetSegOff = 0x20 + rebaseAddAddrUleb = 0x30 + rebaseAddAddrImm = 0x40 + rebaseImmTimes = 0x50 + rebaseUlebTimes = 0x60 + rebaseAddAddrUleb1 = 0x70 + rebaseUlebSkip = 0x80 +) + +// machoDyldInfoFixups decodes an LC_DYLD_INFO(_ONLY) command's rebase and bind +// (regular / weak / lazy) opcode streams, appending to out. +func machoDyldInfoFixups(out []Reloc, cmd, raw []byte, base uint64, bo binary.ByteOrder, is64 bool, segs []*macho.Segment, dylibs []string, secAt func(uint64) string) []Reloc { + if len(cmd) < 48 { + return out + } + ptr := uint64(4) + if is64 { + ptr = 8 + } + table := func(offField, sizeField int) []byte { + off := uint64(bo.Uint32(cmd[offField:])) + size := uint64(bo.Uint32(cmd[sizeField:])) + return slab(raw, base+off, size) + } + out = machoRebaseOps(out, table(8, 12), ptr, segs, secAt) + out = machoBindOps(out, table(16, 20), "BIND", ptr, segs, dylibs, secAt) + out = machoBindOps(out, table(24, 28), "WEAK_BIND", ptr, segs, dylibs, secAt) + out = machoBindOps(out, table(32, 36), "LAZY_BIND", ptr, segs, dylibs, secAt) + return out +} + +// segAddr resolves a (segment index, in-segment offset) pair to a virtual +// address, guarding against a malformed segment index. +func segAddr(segs []*macho.Segment, idx int, off uint64) (uint64, bool) { + if idx < 0 || idx >= len(segs) { + return 0, false + } + return segs[idx].Addr + off, true +} + +// machoBindOps interprets one dyld bind opcode stream. Each DO_BIND* emits a +// Reloc for the current symbol at the current address, then advances. +func machoBindOps(out []Reloc, data []byte, typ string, ptr uint64, segs []*macho.Segment, dylibs []string, secAt func(uint64) string) []Reloc { + var ( + segIdx = -1 + segOff uint64 + sym string + lib string + ordinal int + ) + emit := func() { + if len(out) >= maxDynamicFixups { + return + } + if addr, ok := segAddr(segs, segIdx, segOff); ok { + out = append(out, Reloc{Offset: addr, Type: typ, Sym: sym, Lib: lib, Section: secAt(addr)}) + } + } + p := 0 + for p < len(data) && len(out) < maxDynamicFixups { + op := data[p] & 0xf0 + imm := int(data[p] & 0x0f) + p++ + switch op { + case bindDone: + // A zero byte terminates the lazy stream between per-symbol runs; keep + // going so every lazy bind is seen, and stop only at end of data. + if typ != "LAZY_BIND" { + return out + } + case bindSetDylibOrdImm: + ordinal = imm + lib = dylibName(ordinal, dylibs) + case bindSetDylibOrdUleb: + var v uint64 + v, p = uleb(data, p) + ordinal = int(v) + lib = dylibName(ordinal, dylibs) + case bindSetDylibSpecial: + // Sign-extended small negative ordinal (self / main / flat lookup) — no + // concrete library. + if imm == 0 { + ordinal = 0 + } else { + ordinal = imm | ^0x0f + } + lib = "" + case bindSetSymbol: + sym, p = cstrAt(data, p) + case bindSetType: + // pointer / text-abs32 / text-pcrel32 — not needed for naming. + case bindSetAddend: + _, p = sleb(data, p) + case bindSetSegOff: + segIdx = imm + segOff, p = uleb(data, p) + case bindAddAddrUleb: + var v uint64 + v, p = uleb(data, p) + segOff += v + case bindDoBind: + emit() + segOff += ptr + case bindDoBindAddUleb: + emit() + var v uint64 + v, p = uleb(data, p) + segOff += ptr + v + case bindDoBindImmScaled: + emit() + segOff += ptr + uint64(imm)*ptr + case bindDoBindUlebSkip: + var count, skip uint64 + count, p = uleb(data, p) + skip, p = uleb(data, p) + for i := uint64(0); i < count && len(out) < maxDynamicFixups; i++ { + emit() + segOff += ptr + skip + } + case bindThreaded: + // LC_DYLD_INFO threaded-rebase/bind (a pre-chained variant) needs its own + // state machine; stop rather than misdecode the rest of the stream. + return out + default: + return out + } + } + return out +} + +// machoRebaseOps interprets one dyld rebase opcode stream, emitting a REBASE +// entry per slid pointer slot. +func machoRebaseOps(out []Reloc, data []byte, ptr uint64, segs []*macho.Segment, secAt func(uint64) string) []Reloc { + var ( + segIdx = -1 + segOff uint64 + ) + emit := func() { + if len(out) >= maxDynamicFixups { + return + } + if addr, ok := segAddr(segs, segIdx, segOff); ok { + out = append(out, Reloc{Offset: addr, Type: "REBASE", Section: secAt(addr)}) + } + } + p := 0 + for p < len(data) && len(out) < maxDynamicFixups { + op := data[p] & 0xf0 + imm := int(data[p] & 0x0f) + p++ + switch op { + case rebaseDone: + return out + case rebaseSetType: + // pointer / text-abs32 / text-pcrel32 — not needed. + case rebaseSetSegOff: + segIdx = imm + segOff, p = uleb(data, p) + case rebaseAddAddrUleb: + var v uint64 + v, p = uleb(data, p) + segOff += v + case rebaseAddAddrImm: + segOff += uint64(imm) * ptr + case rebaseImmTimes: + for i := 0; i < imm && len(out) < maxDynamicFixups; i++ { + emit() + segOff += ptr + } + case rebaseUlebTimes: + var count uint64 + count, p = uleb(data, p) + for i := uint64(0); i < count && len(out) < maxDynamicFixups; i++ { + emit() + segOff += ptr + } + case rebaseAddAddrUleb1: + emit() + var v uint64 + v, p = uleb(data, p) + segOff += ptr + v + case rebaseUlebSkip: + var count, skip uint64 + count, p = uleb(data, p) + skip, p = uleb(data, p) + for i := uint64(0); i < count && len(out) < maxDynamicFixups; i++ { + emit() + segOff += ptr + skip + } + default: + return out + } + } + return out +} + +// --- LC_DYLD_CHAINED_FIXUPS -------------------------------------------------- + +// chained pointer formats (mach-o/fixup-chains.h) we decode. 32-bit and cache +// formats are intentionally omitted (not produced for macOS x86_64/arm64 apps). +const ( + chainPtrArm64e = 1 + chainPtr64 = 2 + chainPtr64Offset = 6 + chainPtrArm64eKernel = 7 + chainPtrArm64eUserland = 9 + chainPtrArm64eFirmware = 10 + chainPtrArm64eUserland24 = 12 + + chainStartNone = 0xffff // DYLD_CHAINED_PTR_START_NONE + chainStartMulti = 0x8000 // DYLD_CHAINED_PTR_START_MULTI + chainStartLast = 0x8000 // DYLD_CHAINED_PTR_START_LAST (in the overflow list) +) + +// chainedImport is one resolved entry of the chained-fixups imports table. +type chainedImport struct { + name string + lib string +} + +// machoChainedFixups decodes an LC_DYLD_CHAINED_FIXUPS command: it reads the +// imports table (ordinal → symbol/library) and walks every segment's pointer +// chains, emitting a bind (named) or rebase entry per link. +func machoChainedFixups(out []Reloc, cmd, raw []byte, base uint64, bo binary.ByteOrder, segs []*macho.Segment, dylibs []string, secAt func(uint64) string) []Reloc { + if len(cmd) < 16 { + return out + } + dataOff := uint64(bo.Uint32(cmd[8:])) + dataSize := uint64(bo.Uint32(cmd[12:])) + hdr := slab(raw, base+dataOff, dataSize) + if len(hdr) < 28 { + return out + } + startsOff := bo.Uint32(hdr[4:]) + importsOff := bo.Uint32(hdr[8:]) + symbolsOff := bo.Uint32(hdr[12:]) + importsCount := bo.Uint32(hdr[16:]) + importsFormat := bo.Uint32(hdr[20:]) + + imports := machoChainedImports(hdr, importsOff, symbolsOff, importsCount, importsFormat, bo, dylibs) + + if int(startsOff)+4 > len(hdr) { + return out + } + segCount := bo.Uint32(hdr[startsOff:]) + for i := uint32(0); i < segCount && len(out) < maxDynamicFixups; i++ { + e := uint64(startsOff) + 4 + uint64(i)*4 + if e+4 > uint64(len(hdr)) { + break + } + segInfo := bo.Uint32(hdr[e:]) + if segInfo == 0 || int(i) >= len(segs) { + continue // no chains in this segment (or a segment we can't map) + } + out = machoChainSegment(out, hdr, uint64(startsOff)+uint64(segInfo), raw, base, bo, segs[i], imports, secAt) + } + return out +} + +// machoChainedImports resolves the imports array into names + libraries. The +// three formats differ only in field widths and whether an addend trails. +func machoChainedImports(hdr []byte, importsOff, symbolsOff, count, format uint32, bo binary.ByteOrder, dylibs []string) []chainedImport { + pool := hdr + if int(symbolsOff) <= len(hdr) { + pool = hdr[symbolsOff:] + } + name := func(off uint32) string { + s, _ := cstrAt(pool, int(off)) + return s + } + var stride uint32 + switch format { + case 2: // DYLD_CHAINED_IMPORT_ADDEND + stride = 8 + case 3: // DYLD_CHAINED_IMPORT_ADDEND64 + stride = 16 + default: // DYLD_CHAINED_IMPORT + stride = 4 + } + out := make([]chainedImport, 0, count) + for i := uint32(0); i < count; i++ { + off := uint64(importsOff) + uint64(i)*uint64(stride) + if off+uint64(stride) > uint64(len(hdr)) { + break + } + var ordinal int + var nameOff uint32 + if format == 3 { + v := bo.Uint64(hdr[off:]) + ordinal = int(v & 0xffff) + nameOff = uint32(v >> 32) + } else { + v := bo.Uint32(hdr[off:]) + ordinal = int(v & 0xff) + nameOff = v >> 9 + } + out = append(out, chainedImport{name: name(nameOff), lib: dylibName(ordinal, dylibs)}) + } + return out +} + +// machoChainSegment walks the pointer chains of one segment. startOff is the +// offset within hdr of this segment's dyld_chained_starts_in_segment. +func machoChainSegment(out []Reloc, hdr []byte, startOff uint64, raw []byte, base uint64, bo binary.ByteOrder, seg *macho.Segment, imports []chainedImport, secAt func(uint64) string) []Reloc { + if startOff+22 > uint64(len(hdr)) { + return out + } + s := hdr[startOff:] + pageSize := uint64(bo.Uint16(s[4:])) + pointerFormat := bo.Uint16(s[6:]) + segmentOffset := bo.Uint64(s[8:]) + pageCount := int(bo.Uint16(s[20:])) + if pageSize == 0 { + return out + } + // segmentOffset is the segment's offset from the mach header (its file offset + // for a normal image); vmBase converts an in-segment file delta to a vaddr. + vmBase := seg.Addr - segmentOffset + + for pi := 0; pi < pageCount && len(out) < maxDynamicFixups; pi++ { + po := 22 + pi*2 + if po+2 > len(s) { + break + } + start := bo.Uint16(s[po:]) + if start == chainStartNone { + continue + } + for _, chainOff := range machoChainStartsOnPage(s, pageCount, start, bo) { + pageBase := segmentOffset + uint64(pi)*pageSize + out = machoWalkChain(out, raw, base, bo, pageBase+uint64(chainOff), vmBase, pointerFormat, imports, secAt) + } + } + return out +} + +// machoChainStartsOnPage returns the in-page byte offsets at which chains begin +// on a page. The common case is a single start; a START_MULTI page indexes an +// overflow list of offsets (terminated by START_LAST) appended after page_start. +func machoChainStartsOnPage(s []byte, pageCount int, start uint16, bo binary.ByteOrder) []uint16 { + if start&chainStartMulti == 0 { + return []uint16{start} + } + var starts []uint16 + idx := pageCount + int(start&^chainStartMulti) + for { + po := 22 + idx*2 + if po+2 > len(s) || len(starts) > 0x10000 { + break + } + v := bo.Uint16(s[po:]) + starts = append(starts, v&^chainStartLast) + if v&chainStartLast != 0 { + break + } + idx++ + } + return starts +} + +// machoWalkChain follows one pointer chain from fileOff (relative to base), +// emitting a bind or rebase per link until a next-delta of 0 ends it. vmBase maps +// the link's file offset back to its virtual address. +func machoWalkChain(out []Reloc, raw []byte, base uint64, bo binary.ByteOrder, fileOff, vmBase uint64, format uint16, imports []chainedImport, secAt func(uint64) string) []Reloc { + off := fileOff + for guard := 0; guard < maxDynamicFixups && len(out) < maxDynamicFixups; guard++ { + abs := base + off + if abs+8 > uint64(len(raw)) { + break + } + v := bo.Uint64(raw[abs:]) + isBind, auth, ordinal, next, stride, ok := decodeChainedPtr(v, format) + if !ok { + break + } + addr := vmBase + off + if isBind { + typ := "BIND" + var sym, lib string + if ordinal >= 0 && ordinal < len(imports) { + sym = imports[ordinal].name + lib = imports[ordinal].lib + } + if auth { + typ = "AUTH_BIND" + } + out = append(out, Reloc{Offset: addr, Type: typ, Sym: sym, Lib: lib, Section: secAt(addr)}) + } else { + typ := "REBASE" + if auth { + typ = "AUTH_REBASE" + } + out = append(out, Reloc{Offset: addr, Type: typ, Section: secAt(addr)}) + } + if next == 0 { + break + } + off += uint64(next) * stride + } + return out +} + +// decodeChainedPtr splits a raw chained pointer per its format into the fields +// this decoder needs: whether it's a bind (vs rebase), whether it's authenticated +// (arm64e), the import ordinal (binds), the next-link delta and its stride. ok is +// false for formats we don't decode (32-bit / cache), stopping the chain. +func decodeChainedPtr(v uint64, format uint16) (isBind, auth bool, ordinal int, next uint32, stride uint64, ok bool) { + switch format { + case chainPtr64, chainPtr64Offset: + // next:12 at bits 51..62, bind:1 at bit 63; bind ordinal is 24 bits. + isBind = v>>63&1 != 0 + next = uint32(v >> 51 & 0xfff) + if isBind { + ordinal = int(v & 0xffffff) + } + return isBind, false, ordinal, next, 4, true + case chainPtrArm64e, chainPtrArm64eUserland, chainPtrArm64eUserland24, + chainPtrArm64eKernel, chainPtrArm64eFirmware: + // next:11 at bits 51..61, bind:1 at bit 62, auth:1 at bit 63. + auth = v>>63&1 != 0 + isBind = v>>62&1 != 0 + next = uint32(v >> 51 & 0x7ff) + if isBind { + if format == chainPtrArm64eUserland24 { + ordinal = int(v & 0xffffff) + } else { + ordinal = int(v & 0xffff) + } + } + stride = 8 + if format == chainPtrArm64eKernel || format == chainPtrArm64eFirmware { + stride = 4 + } + return isBind, auth, ordinal, next, stride, true + } + return false, false, 0, 0, 0, false +} + +// --- LEB128 + string helpers ------------------------------------------------- + +// uleb reads an unsigned LEB128 at data[p], returning the value and the next +// position. A truncated encoding stops at end of data. +func uleb(data []byte, p int) (uint64, int) { + var result uint64 + var shift uint + for p < len(data) { + b := data[p] + p++ + if shift < 64 { + result |= uint64(b&0x7f) << shift + } + if b&0x80 == 0 { + break + } + shift += 7 + } + return result, p +} + +// sleb reads a signed LEB128 at data[p]. +func sleb(data []byte, p int) (int64, int) { + var result int64 + var shift uint + var b byte + for p < len(data) { + b = data[p] + p++ + result |= int64(b&0x7f) << shift + shift += 7 + if b&0x80 == 0 { + break + } + } + if shift < 64 && b&0x40 != 0 { + result |= -1 << shift + } + return result, p +} + +// cstrAt reads a NUL-terminated string at data[p], returning it and the position +// just past the terminator. +func cstrAt(data []byte, p int) (string, int) { + if p < 0 || p >= len(data) { + return "", p + } + for i := p; i < len(data); i++ { + if data[i] == 0 { + return string(data[p:i]), i + 1 + } + } + return string(data[p:]), len(data) +} diff --git a/internal/binfile/macho_fixups_test.go b/internal/binfile/macho_fixups_test.go new file mode 100644 index 0000000..1dbd9da --- /dev/null +++ b/internal/binfile/macho_fixups_test.go @@ -0,0 +1,195 @@ +package binfile + +import ( + "debug/macho" + "runtime" + "strings" + "testing" +) + +// fakeSegs builds segments at known addresses for the opcode-stream tests. +func fakeSegs(addrs ...uint64) []*macho.Segment { + segs := make([]*macho.Segment, len(addrs)) + for i, a := range addrs { + segs[i] = &macho.Segment{SegmentHeader: macho.SegmentHeader{Addr: a}} + } + return segs +} + +func noSection(uint64) string { return "" } + +func TestUlebSleb(t *testing.T) { + // 0x9c 0x01 -> 156; 0x00 -> 0; 0xe5 0x8e 0x26 -> 624485. + if v, p := uleb([]byte{0x9c, 0x01}, 0); v != 156 || p != 2 { + t.Errorf("uleb 156: got %d, p=%d", v, p) + } + if v, p := uleb([]byte{0xe5, 0x8e, 0x26}, 0); v != 624485 || p != 3 { + t.Errorf("uleb 624485: got %d, p=%d", v, p) + } + // sleb 0x7f -> -1; 0x80 0x7f -> -128. + if v, _ := sleb([]byte{0x7f}, 0); v != -1 { + t.Errorf("sleb -1: got %d", v) + } + if v, _ := sleb([]byte{0x80, 0x7f}, 0); v != -128 { + t.Errorf("sleb -128: got %d", v) + } +} + +func TestBindOpsStream(t *testing.T) { + segs := fakeSegs(0x1000, 0x2000) + dylibs := []string{"/usr/lib/libSystem.B.dylib"} + // ordinal 1; symbol "_foo"; type pointer; seg 0 off 0x10; DO_BIND; + // DO_BIND_ADD_ADDR_IMM_SCALED with imm 1; DONE. + stream := []byte{} + stream = append(stream, bindSetDylibOrdImm|0x01) + stream = append(stream, bindSetSymbol|0x00) + stream = append(stream, []byte("_foo")...) + stream = append(stream, 0x00) + stream = append(stream, bindSetType|0x01) + stream = append(stream, bindSetSegOff|0x00, 0x10) + stream = append(stream, bindDoBind) + stream = append(stream, bindDoBindImmScaled|0x01) + stream = append(stream, bindDone) + + got := machoBindOps(nil, stream, "BIND", 8, segs, dylibs, noSection) + if len(got) != 2 { + t.Fatalf("expected 2 binds, got %d: %+v", len(got), got) + } + if got[0].Offset != 0x1010 || got[1].Offset != 0x1018 { + t.Errorf("bind addresses: got 0x%x, 0x%x; want 0x1010, 0x1018", got[0].Offset, got[1].Offset) + } + for _, r := range got { + if r.Sym != "_foo" || r.Lib != dylibs[0] || r.Type != "BIND" { + t.Errorf("bind fields: %+v", r) + } + } +} + +func TestBindOpsUlebTimesSkipping(t *testing.T) { + segs := fakeSegs(0x4000) + // seg 0 off 0; DO_BIND_ULEB_TIMES_SKIPPING_ULEB count=3 skip=8; DONE. + // each iteration advances ptr(8)+skip(8)=16. + stream := []byte{ + bindSetSymbol, '_', 'b', 0x00, + bindSetSegOff, 0x00, + bindDoBindUlebSkip, 0x03, 0x08, + bindDone, + } + got := machoBindOps(nil, stream, "LAZY_BIND", 8, segs, nil, noSection) + want := []uint64{0x4000, 0x4010, 0x4020} + if len(got) != len(want) { + t.Fatalf("expected %d binds, got %d", len(want), len(got)) + } + for i, w := range want { + if got[i].Offset != w { + t.Errorf("bind %d: got 0x%x, want 0x%x", i, got[i].Offset, w) + } + } +} + +func TestRebaseOpsStream(t *testing.T) { + segs := fakeSegs(0x8000) + // seg 0 off 0; DO_REBASE_IMM_TIMES 3; ADD_ADDR_IMM_SCALED 1; DO_REBASE_ULEB_TIMES 2; DONE. + stream := []byte{ + rebaseSetType | 0x01, + rebaseSetSegOff, 0x00, + rebaseImmTimes | 0x03, + rebaseAddAddrImm | 0x01, + rebaseUlebTimes, 0x02, + rebaseDone, + } + got := machoRebaseOps(nil, stream, 8, segs, noSection) + // imm-times: 0x8000, 0x8008, 0x8010 (segOff now 0x18); add imm*ptr=8 -> 0x20; + // uleb-times 2: 0x8020, 0x8028. + want := []uint64{0x8000, 0x8008, 0x8010, 0x8020, 0x8028} + if len(got) != len(want) { + t.Fatalf("expected %d rebases, got %d: %+v", len(want), len(got), got) + } + for i, w := range want { + if got[i].Offset != w || got[i].Type != "REBASE" { + t.Errorf("rebase %d: got 0x%x/%s, want 0x%x/REBASE", i, got[i].Offset, got[i].Type, w) + } + } +} + +func TestDecodeChainedPtr(t *testing.T) { + // DYLD_CHAINED_PTR_64 bind: bind@63, next@51..62 (12b), ordinal low 24. + v := uint64(1)<<63 | uint64(2)<<51 | 5 + isBind, auth, ord, next, stride, ok := decodeChainedPtr(v, chainPtr64) + if !ok || !isBind || auth || ord != 5 || next != 2 || stride != 4 { + t.Errorf("ptr64 bind: bind=%v auth=%v ord=%d next=%d stride=%d ok=%v", isBind, auth, ord, next, stride, ok) + } + // DYLD_CHAINED_PTR_64 rebase: bind bit clear. + v = uint64(3) << 51 + isBind, _, _, next, stride, ok = decodeChainedPtr(v, chainPtr64) + if !ok || isBind || next != 3 || stride != 4 { + t.Errorf("ptr64 rebase: bind=%v next=%d stride=%d ok=%v", isBind, next, stride, ok) + } + // arm64e userland auth-bind: auth@63, bind@62, next@51..61 (11b), ordinal low 16. + v = uint64(1)<<63 | uint64(1)<<62 | uint64(1)<<51 | 7 + isBind, auth, ord, next, stride, ok = decodeChainedPtr(v, chainPtrArm64eUserland) + if !ok || !isBind || !auth || ord != 7 || next != 1 || stride != 8 { + t.Errorf("arm64e auth-bind: bind=%v auth=%v ord=%d next=%d stride=%d ok=%v", isBind, auth, ord, next, stride, ok) + } + // arm64e userland24 bind: 24-bit ordinal. + v = uint64(1)<<62 | 0x123456 + _, _, ord, _, _, _ = decodeChainedPtr(v, chainPtrArm64eUserland24) + if ord != 0x123456 { + t.Errorf("userland24 ordinal: got 0x%x, want 0x123456", ord) + } + // 32-bit / cache formats we don't decode -> ok=false stops the chain. + if _, _, _, _, _, ok := decodeChainedPtr(0, 3); ok { + t.Errorf("32-bit format should not decode") + } +} + +func TestUlebTruncatedTerminates(t *testing.T) { + // A continuation-bit-only tail must not loop past the buffer. + if v, p := uleb([]byte{0x80, 0x80}, 0); p != 2 || v != 0 { + t.Errorf("truncated uleb: v=%d p=%d", v, p) + } +} + +// TestMachoDynamicFixupsRealBinary is the end-to-end check against a real linked +// image: /bin/ls (chained fixups on macOS) must yield named binds resolved to +// libSystem and a populated relocs list — the whole point of the decoder. +func TestMachoDynamicFixupsRealBinary(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("needs a system Mach-O") + } + f, err := Open("/bin/ls") + if err != nil { + t.Skipf("open /bin/ls: %v", err) + } + defer f.Close() + if !f.HasRelocs() { + t.Fatal("HasRelocs is false for a linked Mach-O with chained fixups") + } + rs := f.Relocations() + binds, rebases, named := 0, 0, 0 + sawLibSystem := false + for _, r := range rs { + switch { + case strings.Contains(r.Type, "BIND"): + binds++ + case strings.Contains(r.Type, "REBASE"): + rebases++ + } + if r.Sym != "" { + named++ + if r.Offset == 0 { + t.Errorf("named reloc %q has zero address", r.Sym) + } + } + if strings.Contains(r.Lib, "libSystem") { + sawLibSystem = true + } + } + t.Logf("/bin/ls: %d relocs, %d binds, %d rebases, %d named", len(rs), binds, rebases, named) + if binds == 0 || named == 0 { + t.Fatal("no named binds decoded from /bin/ls") + } + if !sawLibSystem { + t.Error("expected at least one import resolved to libSystem") + } +} diff --git a/internal/binfile/model.go b/internal/binfile/model.go index fad7dcc..1ec9568 100644 --- a/internal/binfile/model.go +++ b/internal/binfile/model.go @@ -5,8 +5,11 @@ package binfile import ( + "bufio" "cmp" + "container/list" "debug/dwarf" + "io" "os" "path/filepath" "runtime" @@ -14,6 +17,7 @@ import ( "sort" "strings" "sync" + "unsafe" "github.com/rabarbra/exex/internal/arch" ) @@ -216,12 +220,18 @@ type File struct { sourceFiles []string // sorted DWARF source filenames, without line rows linesOnce sync.Once // guards the lazy line-table decode indexOnce sync.Once // builds line lookup indexes from lines - lineCols map[lineKey][]int // distinct DWARF columns by source file:line - lineMap map[string]map[int]bool - lineAddr map[lineKey]uint64 // lowest mapped address per source file:line - fileLines map[string][]int // sorted distinct mapped line numbers per file - sources map[string][]string // resolved file -> lines - sourceExists map[string]bool // resolved file -> exists on disk (cheap presence) + lineFileIDs map[string]int32 // source filename -> lineFiles index + lineCols map[lineKey][]int // distinct DWARF columns by compact file:line + lineAddr map[lineKey]uint64 // lowest mapped address per compact file:line + fileLines [][]int32 // sorted distinct mapped lines by lineFiles index + + sourceMu sync.Mutex + sourceCache map[string]*list.Element + sourceLRU list.List + sourceCacheBytes int + sourceCacheBudget int + sourceCacheMaxEntries int + sourceExists map[string]bool // resolved file -> exists on disk (cheap presence) vaImage *Image // all mapped sections, in VA order (lazy) execImage *Image // executable sections only, in VA order (lazy) @@ -244,10 +254,21 @@ type lineEntry struct { } type lineKey struct { - File string - Line int + File int32 + Line int32 +} + +type sourceCacheEntry struct { + name string + lines []string + weight int } +const ( + defaultSourceCacheBudget = 32 << 20 + defaultSourceCacheEntries = 128 +) + // finalizeSymbols sorts symbols and builds the address index. Demangling is // intentionally NOT done here — it's the slowest part of loading a big symbol // table, so callers run it separately (ComputeDemangled/ApplyDemangled) off the @@ -389,7 +410,7 @@ func hasExplicitSameAddr(symbols []Symbol, explicitSize []bool, addrIdx []int, p // demangle.Filter is pure, so the C++/Rust pass is fanned out across cores. func (f *File) ComputeDemangled() []string { out := make([]string, len(f.Symbols)) - n := max(runtime.NumCPU(), 1) + n := demangleWorkers(runtime.GOMAXPROCS(0)) itanium := func(lo, hi int) { for i := lo; i < hi; i++ { out[i] = demangleName(f.Symbols[i].Name) @@ -411,6 +432,10 @@ func (f *File) ComputeDemangled() []string { return out } +func demangleWorkers(procs int) int { + return min(max(procs, 1), 8) +} + // ApplyDemangled stores the result of ComputeDemangled onto the symbols. Run it // on the File's owning goroutine. func (f *File) ApplyDemangled(d []string) { @@ -464,7 +489,10 @@ func (f *File) lineEntries() []lineEntry { f.ensureDWARF() if f.dwarf != nil { f.lines, f.lineFiles = loadLines(f.dwarf) + f.dwarfAvail = true + f.dwarf = nil } + f.ensureSourceFilesFromLines() }) return f.lines } @@ -481,65 +509,44 @@ func (f *File) ensureDWARF() { }) } -// WarmDebugInfo eagerly performs the normally-lazy DWARF *parse* (dwarf.Data) so -// the source pane / Sources view start from a warm decode. It deliberately does -// NOT build the address→line table and lookup maps: those are large (hundreds of -// MB and millions of entries on a rich debug binary) and would be wasted heap if -// the user never opens source — they stay lazy until first source access. Safe -// from a background goroutine (Once-guarded); a no-op without debug info. -func (f *File) WarmDebugInfo() { - if !f.HasDWARF() { - return - } - f.ensureDWARF() -} - // ensureLineIndexes builds source-line lookup maps from the lazy DWARF line table. func (f *File) ensureLineIndexes() { f.indexOnce.Do(func() { - colsSeen := map[lineKey]map[int]bool{} - lineMap := map[string]map[int]bool{} + lines := f.lineEntries() + lineFileIDs := make(map[string]int32, len(f.lineFiles)) + for i, file := range f.lineFiles { + lineFileIDs[file] = int32(i) + } lineAddr := map[lineKey]uint64{} - for _, le := range f.lineEntries() { - file := f.lineFileName(le.File) - if file == "" || le.Line == 0 { + lineCols := map[lineKey][]int{} + for _, le := range lines { + if le.File < 0 || int(le.File) >= len(f.lineFiles) || le.Line <= 0 { continue } - line := int(le.Line) - if lineMap[file] == nil { - lineMap[file] = map[int]bool{} - } - lineMap[file][line] = true - key := lineKey{File: file, Line: line} + key := lineKey{File: le.File, Line: le.Line} if a, ok := lineAddr[key]; !ok || le.Addr < a { lineAddr[key] = le.Addr } if le.Col > 0 { - if colsSeen[key] == nil { - colsSeen[key] = map[int]bool{} + col := int(le.Col) + if !slices.Contains(lineCols[key], col) { + lineCols[key] = append(lineCols[key], col) } - colsSeen[key][int(le.Col)] = true } } - lineCols := make(map[lineKey][]int, len(colsSeen)) - for key, seen := range colsSeen { - cols := make([]int, 0, len(seen)) - for col := range seen { - cols = append(cols, col) - } + for key, cols := range lineCols { sort.Ints(cols) lineCols[key] = cols } - fileLines := make(map[string][]int, len(lineMap)) - for file, lines := range lineMap { - ls := make([]int, 0, len(lines)) - for ln := range lines { - ls = append(ls, ln) - } - sort.Ints(ls) - fileLines[file] = ls + fileLines := make([][]int32, len(f.lineFiles)) + for key := range lineAddr { + fileLines[key.File] = append(fileLines[key.File], key.Line) + } + for i, file := range fileLines { + slices.Sort(file) + fileLines[i] = slices.Compact(file) } - f.lineMap = lineMap + f.lineFileIDs = lineFileIDs f.lineCols = lineCols f.lineAddr = lineAddr f.fileLines = fileLines @@ -612,6 +619,48 @@ func loadLines(d *dwarf.Data) ([]lineEntry, []string) { return out, files } +// SourceFiles returns the sorted, de-duplicated set of source files referenced +// by the DWARF line table. It does not retain address rows, so the detailed line +// table stays lazy until source-aware disassembly needs it. +func (f *File) SourceFiles() []string { + if f.sourceFiles == nil { + if len(f.lineFiles) > 0 { + f.ensureSourceFilesFromLines() + } else { + f.ensureDWARF() + if f.dwarf != nil { + f.sourceFiles = sourceFilesOnly(f.dwarf) + } else { + f.sourceFiles = []string{} + } + } + } + out := make([]string, len(f.sourceFiles)) + copy(out, f.sourceFiles) + return out +} + +func (f *File) ensureSourceFilesFromLines() { + if len(f.lineFiles) == 0 { + if f.sourceFiles == nil { + f.sourceFiles = []string{} + } + return + } + seen := make(map[string]bool, len(f.sourceFiles)+len(f.lineFiles)) + for _, file := range f.sourceFiles { + seen[file] = true + } + for _, file := range f.lineFiles { + seen[file] = true + } + f.sourceFiles = make([]string, 0, len(seen)) + for file := range seen { + f.sourceFiles = append(f.sourceFiles, file) + } + sort.Strings(f.sourceFiles) +} + func sourceFilesOnly(d *dwarf.Data) []string { seen := map[string]bool{} r := d.Reader() @@ -637,44 +686,24 @@ func sourceFilesOnly(d *dwarf.Data) []string { } } addFiles() - var le dwarf.LineEntry + var entry dwarf.LineEntry for { - if err := lr.Next(&le); err != nil { + if err := lr.Next(&entry); err != nil { break } - if le.File != nil && le.File.Name != "" { - seen[le.File.Name] = true + if entry.File != nil && entry.File.Name != "" { + seen[entry.File.Name] = true } } - addFiles() + addFiles() // DW_LNE_define_file can grow the table while it is read. r.SkipChildren() } - out := make([]string, 0, len(seen)) - for name := range seen { - out = append(out, name) - } - sort.Strings(out) - return out -} - -// SourceFiles returns the sorted, de-duplicated set of source files referenced -// by the DWARF line table. It reads only the per-CU file tables and does not -// build the full address-sorted line-entry table used by source lookup. -func (f *File) SourceFiles() []string { - if f.sourceFiles == nil { - f.ensureDWARF() - if f.sourceFiles == nil && f.dwarf != nil { - f.sourceFiles = sourceFilesOnly(f.dwarf) - } else if f.sourceFiles == nil && len(f.lineFiles) > 0 { - f.sourceFiles = append([]string(nil), f.lineFiles...) - sort.Strings(f.sourceFiles) - } else if f.sourceFiles == nil { - f.sourceFiles = []string{} - } + files := make([]string, 0, len(seen)) + for file := range seen { + files = append(files, file) } - out := make([]string, len(f.sourceFiles)) - copy(out, f.sourceFiles) - return out + sort.Strings(files) + return files } // LineToAddr returns an address that maps to file:line — the lowest address at @@ -682,18 +711,30 @@ func (f *File) SourceFiles() []string { // mapped line at or after it in the same file. func (f *File) LineToAddr(file string, line int) (uint64, bool) { f.ensureLineIndexes() - if a, ok := f.lineAddr[lineKey{File: file, Line: line}]; ok { + fileID, ok := f.lineFileIDs[file] + compact, valid := compactLineNumber(line) + if !ok || !valid { + return 0, false + } + if a, ok := f.lineAddr[lineKey{File: fileID, Line: compact}]; ok { return a, true } - lines := f.fileLines[file] - i := sort.Search(len(lines), func(i int) bool { return lines[i] >= line }) + lines := f.fileLines[fileID] + i := sort.Search(len(lines), func(i int) bool { return lines[i] >= compact }) if i >= len(lines) { return 0, false } - a, ok := f.lineAddr[lineKey{File: file, Line: lines[i]}] + a, ok := f.lineAddr[lineKey{File: fileID, Line: lines[i]}] return a, ok } +func compactLineNumber(line int) (int32, bool) { + if line < 0 || int64(line) > 1<<31-1 { + return 0, false + } + return int32(line), true +} + // LookupAddr returns the source file:line covering addr, or "", 0. func (f *File) LookupAddr(addr uint64) (string, int) { file, line, _ := f.LookupAddrCol(addr) @@ -718,13 +759,17 @@ func (f *File) LookupAddrCol(addr uint64) (file string, line, col int) { // code mapped to them. func (f *File) MappedLines(file string) map[int]bool { f.ensureLineIndexes() - lines := f.lineMap[file] + fileID, ok := f.lineFileIDs[file] + if !ok { + return map[int]bool{} + } + lines := f.fileLines[fileID] if len(lines) == 0 { return map[int]bool{} } out := make(map[int]bool, len(lines)) - for line := range lines { - out[line] = true + for _, line := range lines { + out[int(line)] = true } return out } @@ -733,7 +778,12 @@ func (f *File) MappedLines(file string) map[int]bool { // file:line — the positions within the line that code maps to. func (f *File) LineColumns(file string, line int) []int { f.ensureLineIndexes() - return f.lineCols[lineKey{File: file, Line: line}] + fileID, ok := f.lineFileIDs[file] + compact, valid := compactLineNumber(line) + if !ok || !valid { + return nil + } + return f.lineCols[lineKey{File: fileID, Line: compact}] } // SymbolAt returns the symbol whose extent covers addr. @@ -822,19 +872,15 @@ func (it *SymbolRangeIter) Next() (Symbol, bool) { if it.f == nil { return Symbol{}, false } - for ; it.i < len(it.f.symByAddr); it.i++ { - s := it.f.Symbols[it.f.symByAddr[it.i]] - if s.Addr >= it.to { - return Symbol{}, false - } - if s.Size == 0 { - it.i++ - return s, true - } - it.i++ - return s, true + if it.i >= len(it.f.symByAddr) { + return Symbol{}, false } - return Symbol{}, false + s := it.f.Symbols[it.f.symByAddr[it.i]] + if s.Addr >= it.to { + return Symbol{}, false + } + it.i++ + return s, true } func (f *File) symbolRangeStart(from uint64) int { @@ -903,12 +949,15 @@ func (f *File) SourceExists(name string) bool { if name == "" { return false } + f.sourceMu.Lock() if f.sourceExists == nil { f.sourceExists = map[string]bool{} } if v, ok := f.sourceExists[name]; ok { + f.sourceMu.Unlock() return v } + f.sourceMu.Unlock() ok := false for _, c := range f.sourceCandidates(name) { if st, err := os.Stat(c); err == nil && !st.IsDir() { @@ -916,7 +965,9 @@ func (f *File) SourceExists(name string) bool { break } } + f.sourceMu.Lock() f.sourceExists[name] = ok + f.sourceMu.Unlock() return ok } @@ -934,21 +985,88 @@ func (f *File) SourceLines(name string) []string { if name == "" { return nil } - if f.sources == nil { - f.sources = map[string][]string{} - } - if v, ok := f.sources[name]; ok { + if v, ok := f.cachedSourceLines(name); ok { return v } for _, c := range f.sourceCandidates(name) { if b, err := os.ReadFile(c); err == nil { lines := strings.Split(string(b), "\n") - f.sources[name] = lines - return lines + return f.cacheSourceLines(name, lines, len(b)+cap(lines)*int(unsafe.Sizeof(""))) } } - f.sources[name] = nil - return nil + return f.cacheSourceLines(name, nil, 0) +} + +// ScanSourceLines streams one resolved source file without populating the +// display cache. It is used by cross-file grep, where caching every scanned file +// would retain an entire source tree after one query. +func (f *File) ScanSourceLines(name string, yield func(string) bool) bool { + if name == "" || yield == nil { + return false + } + for _, candidate := range f.sourceCandidates(name) { + fp, err := os.Open(candidate) + if err != nil { + continue + } + reader := bufio.NewReader(fp) + for { + line, readErr := reader.ReadString('\n') + if len(line) > 0 { + line = strings.TrimSuffix(line, "\n") + if !yield(line) { + fp.Close() + return true + } + } + if readErr != nil { + fp.Close() + return readErr == io.EOF + } + } + } + return false +} + +func (f *File) cachedSourceLines(name string) ([]string, bool) { + f.sourceMu.Lock() + defer f.sourceMu.Unlock() + if elem := f.sourceCache[name]; elem != nil { + f.sourceLRU.MoveToFront(elem) + return elem.Value.(*sourceCacheEntry).lines, true + } + return nil, false +} + +func (f *File) cacheSourceLines(name string, lines []string, weight int) []string { + f.sourceMu.Lock() + defer f.sourceMu.Unlock() + if f.sourceCache == nil { + f.sourceCache = map[string]*list.Element{} + } + if elem := f.sourceCache[name]; elem != nil { + f.sourceLRU.MoveToFront(elem) + return elem.Value.(*sourceCacheEntry).lines + } + entry := &sourceCacheEntry{name: name, lines: lines, weight: weight} + f.sourceCache[name] = f.sourceLRU.PushFront(entry) + f.sourceCacheBytes += weight + budget := f.sourceCacheBudget + if budget <= 0 { + budget = defaultSourceCacheBudget + } + maxEntries := f.sourceCacheMaxEntries + if maxEntries <= 0 { + maxEntries = defaultSourceCacheEntries + } + for f.sourceLRU.Len() > 1 && (f.sourceCacheBytes > budget || f.sourceLRU.Len() > maxEntries) { + oldest := f.sourceLRU.Back() + old := oldest.Value.(*sourceCacheEntry) + delete(f.sourceCache, old.name) + f.sourceLRU.Remove(oldest) + f.sourceCacheBytes -= old.weight + } + return lines } // HasDWARF reports whether DWARF info was loaded. @@ -966,10 +1084,6 @@ func (f *File) Raw() []byte { return f.raw } // DebugPath returns the explicit external debug-symbols path (--debug), or "". func (f *File) DebugPath() string { return f.debugPath } -// RequestedArch returns the fat-Mach-O slice requested via --arch, or "" when -// none was given (the host/first slice was auto-selected). -func (f *File) RequestedArch() string { return f.reqArch } - // AddrHexWidth is the number of hex digits an address should be printed with. // With the compact-addresses preference set, a 64-bit binary whose addresses all // fit in 32 bits prints 8 digits instead of 16; the true word size is unaffected diff --git a/internal/binfile/overview.go b/internal/binfile/overview.go index 4df7470..42b44fc 100644 --- a/internal/binfile/overview.go +++ b/internal/binfile/overview.go @@ -3,6 +3,7 @@ package binfile import ( "debug/buildinfo" "debug/dwarf" + "sort" "strings" ) @@ -144,25 +145,72 @@ func dwarfLanguage(d *dwarf.Data) string { } } -// dwarfLangName maps the common DW_LANG_* constants to a readable name. +// dwarfLangNames maps the DW_LANG_* constants to a readable name. Several codes +// share a language (C and Fortran each span multiple standard revisions). Keeping +// this as data (rather than a switch) lets DwarfLanguageNames enumerate the set, +// which the syntax-coverage test cross-checks against the curated Chroma lexers. +var dwarfLangNames = map[int64]string{ + 0x0001: "C", 0x0002: "C", 0x000c: "C", 0x001d: "C", 0x002c: "C", + 0x0004: "C++", 0x0019: "C++", 0x001a: "C++", 0x0021: "C++", 0x002a: "C++", 0x002b: "C++", + 0x0003: "Ada", 0x000d: "Ada", 0x002e: "Ada", 0x002f: "Ada", + 0x0005: "COBOL", 0x0006: "COBOL", + 0x0007: "Fortran", 0x0008: "Fortran", 0x000e: "Fortran", 0x0022: "Fortran", 0x0023: "Fortran", 0x002d: "Fortran", + 0x0009: "Pascal", + 0x000a: "Modula-2", + 0x000b: "Java", + 0x0010: "Objective-C", + 0x0011: "Objective-C++", + 0x0012: "UPC", + 0x0013: "D", + 0x0014: "Python", + 0x0015: "OpenCL", + 0x0016: "Go", + 0x0017: "Modula-3", + 0x0018: "Haskell", + 0x001b: "OCaml", + 0x001c: "Rust", + 0x001e: "Swift", + 0x001f: "Julia", + 0x0020: "Dylan", + 0x0024: "RenderScript", + 0x0025: "BLISS", + 0x0026: "Kotlin", + 0x0027: "Zig", + 0x0028: "Crystal", + 0x0030: "HIP", + 0x0031: "Assembly", 0x8001: "Assembly", + 0x0032: "C#", + 0x0033: "Mojo", + 0x0034: "GLSL", + 0x0035: "GLSL ES", + 0x0036: "HLSL", + 0x0037: "OpenCL C++", + 0x0038: "C++ for OpenCL", + 0x0039: "SYCL", + 0x003d: "Metal", + 0x0040: "Ruby", + 0x0041: "Move", + 0x0042: "Hylo", + 0xb000: "Delphi", +} + +// dwarfLangName maps a DW_LANG_* constant to a readable name, or "" if unknown. func dwarfLangName(v int64) string { - switch v { - case 0x0001, 0x0002, 0x000c, 0x001d, 0x0024, 0x0027, 0x0029, 0x002b: - return "C" - case 0x0004, 0x0019, 0x001a, 0x0021, 0x0022, 0x002a: - return "C++" - case 0x0010: - return "Objective-C" - case 0x0011: - return "Objective-C++" - case 0x0016: - return "Go" - case 0x001c: - return "Rust" - case 0x001e: - return "Swift" - case 0x000d: - return "Python" + return dwarfLangNames[v] +} + +// DwarfLanguageNames returns the distinct, sorted set of source-language names +// exex can identify from DWARF. Exposed so the syntax package's coverage test can +// verify each has a curated highlighter (or an explicit minimal-fallback). +func DwarfLanguageNames() []string { + seen := map[string]bool{} + var out []string + for _, name := range dwarfLangNames { + if !seen[name] { + seen[name] = true + out = append(out, name) + } } - return "" + sort.Strings(out) + return out } diff --git a/internal/binfile/overview_test.go b/internal/binfile/overview_test.go index 13f59f6..f3cc037 100644 --- a/internal/binfile/overview_test.go +++ b/internal/binfile/overview_test.go @@ -34,3 +34,25 @@ func TestComputeOverview(t *testing.T) { t.Error("expected Fortify=true from __memcpy_chk") } } + +func TestDwarfLangName(t *testing.T) { + tests := map[int64]string{ + 0x0005: "COBOL", + 0x000a: "Modula-2", + 0x0013: "D", + 0x0018: "Haskell", + 0x001f: "Julia", + 0x0022: "Fortran", + 0x0027: "Zig", + 0x002c: "C", + 0x0032: "C#", + 0x0033: "Mojo", + 0x003d: "Metal", + 0xdead: "", + } + for code, want := range tests { + if got := dwarfLangName(code); got != want { + t.Fatalf("dwarfLangName(0x%x) = %q, want %q", code, got, want) + } + } +} diff --git a/internal/binfile/parse_bench_test.go b/internal/binfile/parse_bench_test.go index ce8bd25..3af4448 100644 --- a/internal/binfile/parse_bench_test.go +++ b/internal/binfile/parse_bench_test.go @@ -2,9 +2,31 @@ package binfile import ( "os" + "runtime" "testing" ) +var retainedSourceBenchmarkFile *File + +func BenchmarkSourceFiles(b *testing.B) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + b.ReportAllocs() + for range b.N { + b.StopTimer() + f, err := Open(path) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + f.SourceFiles() + b.StopTimer() + f.Close() + } +} + // BenchmarkOpen parses EXEX_BENCH_BIN; -benchmem/-memprofile/-cpuprofile // attribute the startup cost. Skipped unless the env var points at a real binary. func BenchmarkOpen(b *testing.B) { @@ -42,3 +64,39 @@ func BenchmarkOpenLayout(b *testing.B) { } } } + +// BenchmarkSourceIndexes measures the first Sources open followed by the +// address/line indexes needed by source-aware disassembly. retained-B is the +// live-heap increase after a GC, excluding the already-open File. +func BenchmarkSourceIndexes(b *testing.B) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + b.ReportAllocs() + for range b.N { + b.StopTimer() + f, err := Open(path) + if err != nil { + b.Fatal(err) + } + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + b.StartTimer() + files := f.SourceFiles() + if len(files) > 0 { + f.MappedLines(files[0]) + } + b.StopTimer() + retainedSourceBenchmarkFile = f + runtime.GC() + var after runtime.MemStats + runtime.ReadMemStats(&after) + if after.HeapAlloc >= before.HeapAlloc { + b.ReportMetric(float64(after.HeapAlloc-before.HeapAlloc), "retained-B") + } + f.Close() + retainedSourceBenchmarkFile = nil + } +} diff --git a/internal/binfile/reloc.go b/internal/binfile/reloc.go index cbe52aa..e87239f 100644 --- a/internal/binfile/reloc.go +++ b/internal/binfile/reloc.go @@ -28,9 +28,9 @@ type Reloc struct { } // Relocations returns the binary's relocation entries, building them on first -// call. The slice may be empty (e.g. a fully-resolved static binary, or a -// Mach-O that uses dyld chained fixups, which the standard library does not -// decode). +// call. The slice may be empty (e.g. a fully-resolved static binary). A linked +// Mach-O's dyld bind/rebase and chained fixups are decoded (machoDynamicFixups), +// not just the object-file per-section relocs the standard library exposes. func (f *File) Relocations() []Reloc { f.relocOnce.Do(func() { if f.relocBuild != nil { @@ -95,6 +95,13 @@ func (f *File) elfRelocs(ef *elf.File) []Reloc { is64 := ef.Class == elf.ELFCLASS64 bo := ef.ByteOrder typeName := elfRelocTypeNamer(ef.Machine) + type symtab struct { + syms []elf.Symbol + dynamic bool + } + symtabs := make(map[uint32]symtab) + var dynSymLib []string + dynSymLibCached := false var out []Reloc for _, s := range ef.Sections { @@ -105,7 +112,15 @@ func (f *File) elfRelocs(ef *elf.File) []Reloc { if err != nil { continue } - syms, libOf := elfRelocSymbols(ef, s) + table, ok := symtabs[s.Link] + if !ok { + table.syms, table.dynamic = elfRelocSymbols(ef, s) + symtabs[s.Link] = table + } + if table.dynamic && !dynSymLibCached { + dynSymLib = elfDynSymLibraries(ef, len(table.syms)) + dynSymLibCached = true + } rela := s.Type == elf.SHT_RELA entSize := elfRelocEntSize(is64, rela) // In a relocatable object r_offset is relative to the section the relocs @@ -133,10 +148,12 @@ func (f *File) elfRelocs(ef *elf.File) []Reloc { r.Addend = int64(int32(bo.Uint32(data[off+8:]))) } } - if sym != 0 && int(sym)-1 < len(syms) { - es := syms[int(sym)-1] + if sym != 0 && int(sym)-1 < len(table.syms) { + es := table.syms[int(sym)-1] r.Sym = es.Name - r.Lib = libOf(sym) + if table.dynamic && int(sym) < len(dynSymLib) { + r.Lib = dynSymLib[sym] + } } out = append(out, r) } @@ -159,29 +176,21 @@ func elfRelocEntSize(is64, rela bool) int { } // elfRelocSymbols returns the symbol table a reloc section is linked to (via -// sh_link: .dynsym for dynamic relocs, .symtab otherwise), plus a helper that -// maps a symbol index to its owning library when versioned. Symbols are in -// index order without the index-0 null entry, so callers index with sym-1. -func elfRelocSymbols(ef *elf.File, s *elf.Section) ([]elf.Symbol, func(uint32) string) { - noLib := func(uint32) string { return "" } +// sh_link: .dynsym for dynamic relocs, .symtab otherwise) and reports whether +// it is the dynamic table. Symbols omit the index-0 null entry. +func elfRelocSymbols(ef *elf.File, s *elf.Section) ([]elf.Symbol, bool) { if int(s.Link) < len(ef.Sections) { if ef.Sections[s.Link].Name == ".symtab" { if syms, err := ef.Symbols(); err == nil { - return syms, noLib + return syms, false } } } dyn, err := ef.DynamicSymbols() if err != nil { - return nil, noLib - } - symLib := elfDynSymLibraries(ef, len(dyn)) - return dyn, func(sym uint32) string { - if int(sym) < len(symLib) { - return symLib[sym] - } - return "" + return nil, false } + return dyn, true } // elfRelocTypeNamer returns a function mapping a raw relocation type number to @@ -215,7 +224,7 @@ func elfRelocTypeNamer(m elf.Machine) func(uint32) string { // machoRelocs collects the per-section relocations the standard library parses // (chiefly object files; linked images use dyld bind/rebase or chained fixups, -// which it does not decode). symNames is indexed like the original Mach-O symtab; +// decoded separately by machoDynamicFixups). symNames is indexed like the original Mach-O symtab; // only names are retained so the bulky parsed symtab can be dropped after load. // base is the slice's virtual-address base. func machoRelocs(mf *macho.File, base uint64, symNames []string) []Reloc { diff --git a/internal/binfile/source_test.go b/internal/binfile/source_test.go index c8dcd22..27b88cd 100644 --- a/internal/binfile/source_test.go +++ b/internal/binfile/source_test.go @@ -1,6 +1,11 @@ package binfile -import "testing" +import ( + "os" + "path/filepath" + "reflect" + "testing" +) func TestSourceFilesAndLineToAddr(t *testing.T) { f := &File{ @@ -54,3 +59,66 @@ func TestSourceFilesAndLineToAddr(t *testing.T) { t.Fatalf("LineColumns = %#v, want [2 9]", cols) } } + +func TestSourceCacheEvictsLeastRecentlyUsed(t *testing.T) { + dir := t.TempDir() + paths := make([]string, 3) + for i, name := range []string{"a.c", "b.c", "c.c"} { + paths[i] = filepath.Join(dir, name) + if err := os.WriteFile(paths[i], []byte(name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + f := &File{sourceCacheBudget: 1 << 20, sourceCacheMaxEntries: 2} + f.SourceLines(paths[0]) + f.SourceLines(paths[1]) + f.SourceLines(paths[0]) // refresh a.c + f.SourceLines(paths[2]) + if f.sourceCache[paths[0]] == nil || f.sourceCache[paths[2]] == nil { + t.Fatal("recent source files were evicted") + } + if f.sourceCache[paths[1]] != nil { + t.Fatal("least recently used source file remained cached") + } +} + +func TestScanSourceLinesDoesNotPopulateDisplayCache(t *testing.T) { + path := filepath.Join(t.TempDir(), "source.c") + if err := os.WriteFile(path, []byte("one\ntwo\nthree"), 0o644); err != nil { + t.Fatal(err) + } + f := &File{} + var got []string + if !f.ScanSourceLines(path, func(line string) bool { + got = append(got, line) + return true + }) { + t.Fatal("source file was not scanned") + } + if !reflect.DeepEqual(got, []string{"one", "two", "three"}) { + t.Fatalf("lines = %#v", got) + } + if len(f.sourceCache) != 0 { + t.Fatal("streaming scan populated the display cache") + } +} + +func TestDetailedSourceIndexesReleaseDWARFData(t *testing.T) { + path, err := os.Executable() + if err != nil { + t.Skip(err) + } + f, err := Open(path) + if err != nil { + t.Skip(err) + } + defer f.Close() + files := f.SourceFiles() + if len(files) == 0 || f.dwarf == nil { + t.Skip("test binary has no readable DWARF line table") + } + f.MappedLines(files[0]) + if f.dwarf != nil { + t.Fatal("parsed DWARF data remained retained after compact line indexes were built") + } +} diff --git a/internal/bytesearch/pattern.go b/internal/bytesearch/pattern.go index f3907fe..ae108ee 100644 --- a/internal/bytesearch/pattern.go +++ b/internal/bytesearch/pattern.go @@ -34,30 +34,68 @@ func NextMode(m Mode) Mode { return (m + 1) % 3 } +// kind is how a query is to be interpreted, decided once by classify. +type kind uint8 + +const ( + kindQuoted kind = iota // "quoted text" -> the bytes between the quotes + kindText // literal text bytes of the raw query + kindHex // hex byte pattern +) + +// classify decides how q is read under mode. ParsePattern and IsTextPattern both +// consult it, so the two can't disagree about what a query means — they used to +// duplicate this rule set, and a change to one silently skipped the other. +// +// It returns the trimmed query and its compacted hex form so callers don't +// recompute them. +func classify(q string, mode Mode) (k kind, trimmed, compact string) { + trimmed = strings.TrimSpace(q) + quoted := len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' + + switch mode { + case ModeText: + if quoted { + return kindQuoted, trimmed, "" + } + return kindText, trimmed, "" + case ModeHex: + return kindHex, trimmed, compactHexPattern(trimmed) + } + + // ModeAuto: quotes force text; otherwise an untrimmed-clean, even-length run + // of hex digits is a byte pattern. Requiring q == trimmed means a query with + // surrounding spaces stays a text search (the spaces are searchable). + if quoted { + return kindQuoted, trimmed, "" + } + compact = compactHexPattern(trimmed) + if q == trimmed && len(compact) >= 2 && len(compact)%2 == 0 && isHexStr(compact) { + return kindHex, trimmed, compact + } + return kindText, trimmed, compact +} + // ParsePattern interprets a query as bytes or text: // - "quoted text" -> literal bytes of the text // - hex digits / 0x -> byte pattern (spaces allowed: "de ad be ef") // - anything else -> literal text bytes func ParsePattern(q string, mode Mode) []byte { - trimmed := strings.TrimSpace(q) - if mode == ModeText { - if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { - return []byte(trimmed[1 : len(trimmed)-1]) - } - return []byte(q) - } - if mode == ModeHex { - compact := compactHexPattern(trimmed) - return parseHexPattern(compact) - } - if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + switch k, trimmed, compact := classify(q, mode); k { + case kindQuoted: return []byte(trimmed[1 : len(trimmed)-1]) - } - compact := compactHexPattern(trimmed) - if q == trimmed && len(compact) >= 2 && len(compact)%2 == 0 && isHexStr(compact) { + case kindHex: return parseHexPattern(compact) + default: + return []byte(q) } - return []byte(q) +} + +// IsTextPattern reports whether ParsePattern would treat q as literal text (so +// case-folding is meaningful) rather than a hex byte pattern (where it isn't). +func IsTextPattern(q string, mode Mode) bool { + k, _, _ := classify(q, mode) + return k != kindHex } // compactHexPattern removes whitespace and an optional 0x/0X prefix. @@ -109,6 +147,61 @@ func FindBytes(data, pat []byte, start int, forward bool) int { return bytes.LastIndex(data[:end], pat) } +// FindBytesFold is FindBytes with optional ASCII case-insensitive matching. With +// fold=false it is FindBytes (the fast exact bytes.Index). With fold=true it +// matches letters ignoring ASCII case — for text patterns; a byte-value pattern +// (hex) simply won't contain letters to fold, so callers can always pass the +// view's case flag. +func FindBytesFold(data, pat []byte, start int, forward, fold bool) int { + if !fold { + return FindBytes(data, pat, start, forward) + } + if len(pat) == 0 || len(pat) > len(data) { + return -1 + } + if forward { + if start < 0 { + start = 0 + } + for i := start; i <= len(data)-len(pat); i++ { + if equalFoldASCII(data[i:i+len(pat)], pat) { + return i + } + } + return -1 + } + end := start + len(pat) + if end > len(data) { + end = len(data) + } + for i := end - len(pat); i >= 0; i-- { + if equalFoldASCII(data[i:i+len(pat)], pat) { + return i + } + } + return -1 +} + +// equalFoldASCII reports whether a and b are equal ignoring ASCII letter case. +func equalFoldASCII(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if lowerASCII(a[i]) != lowerASCII(b[i]) { + return false + } + } + return true +} + +func lowerASCII(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') + } + return c +} + // isHexStr reports whether s is non-empty and contains only hex digits. func isHexStr(s string) bool { if s == "" { diff --git a/internal/bytesearch/pattern_test.go b/internal/bytesearch/pattern_test.go index 27b8cdc..9a5fd69 100644 --- a/internal/bytesearch/pattern_test.go +++ b/internal/bytesearch/pattern_test.go @@ -94,3 +94,40 @@ func TestFindBytes(t *testing.T) { t.Errorf("empty pattern = %d, want -1", got) } } + +func TestFindBytesFold(t *testing.T) { + data := []byte("The Quick Brown FOX") + // insensitive finds regardless of case + if i := FindBytesFold(data, []byte("quick"), 0, true, true); i != 4 { + t.Errorf("fold forward: got %d, want 4", i) + } + if i := FindBytesFold(data, []byte("fox"), 0, true, true); i != 16 { + t.Errorf("fold fox: got %d, want 16", i) + } + // sensitive (fold=false) is exact + if i := FindBytesFold(data, []byte("quick"), 0, true, false); i != -1 { + t.Errorf("no-fold should not match lowercase: got %d, want -1", i) + } + if i := FindBytesFold(data, []byte("Quick"), 0, true, false); i != 4 { + t.Errorf("no-fold exact: got %d, want 4", i) + } + // backward + if i := FindBytesFold(data, []byte("BROWN"), 0x20, false, true); i != 10 { + t.Errorf("fold backward: got %d, want 10", i) + } +} + +func TestIsTextPattern(t *testing.T) { + if !IsTextPattern("hello", ModeAuto) { + t.Error("plain word should be text") + } + if IsTextPattern("deadbeef", ModeAuto) { + t.Error("bare hex should be a byte pattern, not text") + } + if !IsTextPattern("deadbeef", ModeText) { + t.Error("explicit text mode is always text") + } + if IsTextPattern("de ad be ef", ModeHex) { + t.Error("hex mode is never text") + } +} diff --git a/internal/chromalexers/embedded/ada.xml b/internal/chromalexers/embedded/ada.xml new file mode 100644 index 0000000..5854a20 --- /dev/null +++ b/internal/chromalexers/embedded/ada.xml @@ -0,0 +1,321 @@ + + + Ada + ada + ada95 + ada2005 + *.adb + *.ads + *.ada + text/x-ada + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/arduino.xml b/internal/chromalexers/embedded/arduino.xml new file mode 100644 index 0000000..6a75df5 --- /dev/null +++ b/internal/chromalexers/embedded/arduino.xml @@ -0,0 +1,322 @@ + + + Arduino + arduino + *.ino + text/x-arduino + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/armasm.xml b/internal/chromalexers/embedded/armasm.xml new file mode 100644 index 0000000..340278d --- /dev/null +++ b/internal/chromalexers/embedded/armasm.xml @@ -0,0 +1,126 @@ + + + ArmAsm + armasm + *.s + *.S + text/x-armasm + text/x-asm + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/ballerina.xml b/internal/chromalexers/embedded/ballerina.xml new file mode 100644 index 0000000..d13c123 --- /dev/null +++ b/internal/chromalexers/embedded/ballerina.xml @@ -0,0 +1,97 @@ + + + Ballerina + ballerina + *.bal + text/x-ballerina + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/bash.xml b/internal/chromalexers/embedded/bash.xml new file mode 100644 index 0000000..6163cc6 --- /dev/null +++ b/internal/chromalexers/embedded/bash.xml @@ -0,0 +1,222 @@ + + + Bash + bash + sh + ksh + zsh + shell + *.sh + *.ksh + *.bash + *.ebuild + *.eclass + .env + .env.* + *.env + *.exheres-0 + *.exlib + *.zsh + *.zshrc + .bashrc + bashrc + .bash_* + bash_* + zshrc + .zshrc + APKBUILD + PKGBUILD + application/x-sh + application/x-shellscript + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/beef.xml b/internal/chromalexers/embedded/beef.xml new file mode 100644 index 0000000..031a220 --- /dev/null +++ b/internal/chromalexers/embedded/beef.xml @@ -0,0 +1,120 @@ + + + Beef + beef + *.bf + text/x-beef + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/c#.xml b/internal/chromalexers/embedded/c#.xml new file mode 100644 index 0000000..f1e21db --- /dev/null +++ b/internal/chromalexers/embedded/c#.xml @@ -0,0 +1,121 @@ + + + C# + csharp + c# + *.cs + text/x-csharp + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/c++.xml b/internal/chromalexers/embedded/c++.xml new file mode 100644 index 0000000..680a19a --- /dev/null +++ b/internal/chromalexers/embedded/c++.xml @@ -0,0 +1,331 @@ + + + C++ + cpp + c++ + *.cpp + *.hpp + *.c++ + *.h++ + *.cc + *.hh + *.cxx + *.hxx + *.C + *.H + *.cp + *.CPP + *.tpp + text/x-c++hdr + text/x-c++src + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/c.xml b/internal/chromalexers/embedded/c.xml new file mode 100644 index 0000000..35ee32d --- /dev/null +++ b/internal/chromalexers/embedded/c.xml @@ -0,0 +1,260 @@ + + + C + c + *.c + *.h + *.idc + *.x[bp]m + text/x-chdr + text/x-csrc + image/x-xbitmap + image/x-xpixmap + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/c3.xml b/internal/chromalexers/embedded/c3.xml new file mode 100644 index 0000000..8094ce4 --- /dev/null +++ b/internal/chromalexers/embedded/c3.xml @@ -0,0 +1,374 @@ + + + C3 + c3 + *.c3 + *.c3i + *.c3t + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/chapel.xml b/internal/chromalexers/embedded/chapel.xml new file mode 100644 index 0000000..c89cafc --- /dev/null +++ b/internal/chromalexers/embedded/chapel.xml @@ -0,0 +1,143 @@ + + + Chapel + chapel + chpl + *.chpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/clojure.xml b/internal/chromalexers/embedded/clojure.xml new file mode 100644 index 0000000..967ba39 --- /dev/null +++ b/internal/chromalexers/embedded/clojure.xml @@ -0,0 +1,71 @@ + + + Clojure + clojure + clj + edn + *.clj + *.edn + text/x-clojure + application/x-clojure + application/edn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/cmake.xml b/internal/chromalexers/embedded/cmake.xml new file mode 100644 index 0000000..b041cfd --- /dev/null +++ b/internal/chromalexers/embedded/cmake.xml @@ -0,0 +1,90 @@ + + + CMake + cmake + *.cmake + CMakeLists.txt + text/x-cmake + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/cobol.xml b/internal/chromalexers/embedded/cobol.xml new file mode 100644 index 0000000..a8a8029 --- /dev/null +++ b/internal/chromalexers/embedded/cobol.xml @@ -0,0 +1,90 @@ + + + COBOL + cobol + *.cob + *.COB + *.cpy + *.CPY + text/x-cobol + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/crystal.xml b/internal/chromalexers/embedded/crystal.xml new file mode 100644 index 0000000..94853db --- /dev/null +++ b/internal/chromalexers/embedded/crystal.xml @@ -0,0 +1,762 @@ + + + Crystal + cr + crystal + *.cr + text/x-crystal + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/cython.xml b/internal/chromalexers/embedded/cython.xml new file mode 100644 index 0000000..15dfe4d --- /dev/null +++ b/internal/chromalexers/embedded/cython.xml @@ -0,0 +1,372 @@ + + + Cython + cython + pyx + pyrex + *.pyx + *.pxd + *.pxi + text/x-cython + application/x-cython + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/d.xml b/internal/chromalexers/embedded/d.xml new file mode 100644 index 0000000..19c85e2 --- /dev/null +++ b/internal/chromalexers/embedded/d.xml @@ -0,0 +1,133 @@ + + + D + d + *.d + *.di + text/x-d + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/dart.xml b/internal/chromalexers/embedded/dart.xml new file mode 100644 index 0000000..4044f49 --- /dev/null +++ b/internal/chromalexers/embedded/dart.xml @@ -0,0 +1,213 @@ + + + Dart + dart + *.dart + text/x-dart + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/diff.xml b/internal/chromalexers/embedded/diff.xml new file mode 100644 index 0000000..dc0beb7 --- /dev/null +++ b/internal/chromalexers/embedded/diff.xml @@ -0,0 +1,52 @@ + + + Diff + diff + udiff + *.diff + *.patch + text/x-diff + text/x-patch + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/docker.xml b/internal/chromalexers/embedded/docker.xml new file mode 100644 index 0000000..261834f --- /dev/null +++ b/internal/chromalexers/embedded/docker.xml @@ -0,0 +1,68 @@ + + + Docker + docker + dockerfile + containerfile + Dockerfile + Dockerfile.* + *.Dockerfile + *.docker + Containerfile + Containerfile.* + *.Containerfile + text/x-dockerfile-config + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/dylan.xml b/internal/chromalexers/embedded/dylan.xml new file mode 100644 index 0000000..3660d14 --- /dev/null +++ b/internal/chromalexers/embedded/dylan.xml @@ -0,0 +1,176 @@ + + + Dylan + dylan + *.dylan + *.dyl + *.intr + text/x-dylan + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/fish.xml b/internal/chromalexers/embedded/fish.xml new file mode 100644 index 0000000..deb7814 --- /dev/null +++ b/internal/chromalexers/embedded/fish.xml @@ -0,0 +1,159 @@ + + + Fish + fish + fishshell + *.fish + *.load + application/x-fish + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/fortran.xml b/internal/chromalexers/embedded/fortran.xml new file mode 100644 index 0000000..6140e70 --- /dev/null +++ b/internal/chromalexers/embedded/fortran.xml @@ -0,0 +1,102 @@ + + + Fortran + fortran + f90 + *.f03 + *.f90 + *.f95 + *.F03 + *.F90 + *.F95 + text/x-fortran + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/fortranfixed.xml b/internal/chromalexers/embedded/fortranfixed.xml new file mode 100644 index 0000000..11343c0 --- /dev/null +++ b/internal/chromalexers/embedded/fortranfixed.xml @@ -0,0 +1,71 @@ + + + FortranFixed + fortranfixed + *.f + *.F + text/x-fortran + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/fsharp.xml b/internal/chromalexers/embedded/fsharp.xml new file mode 100644 index 0000000..e1c19ff --- /dev/null +++ b/internal/chromalexers/embedded/fsharp.xml @@ -0,0 +1,245 @@ + + + FSharp + fsharp + *.fs + *.fsi + text/x-fsharp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/gas.xml b/internal/chromalexers/embedded/gas.xml new file mode 100644 index 0000000..399cdd0 --- /dev/null +++ b/internal/chromalexers/embedded/gas.xml @@ -0,0 +1,150 @@ + + + GAS + gas + asm + *.s + *.S + text/x-gas + 0.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/glsl.xml b/internal/chromalexers/embedded/glsl.xml new file mode 100644 index 0000000..ca0b696 --- /dev/null +++ b/internal/chromalexers/embedded/glsl.xml @@ -0,0 +1,65 @@ + + + GLSL + glsl + *.vert + *.frag + *.geo + text/x-glslsrc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/groovy.xml b/internal/chromalexers/embedded/groovy.xml new file mode 100644 index 0000000..3cca2e9 --- /dev/null +++ b/internal/chromalexers/embedded/groovy.xml @@ -0,0 +1,135 @@ + + + Groovy + groovy + *.groovy + *.gradle + text/x-groovy + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/hare.xml b/internal/chromalexers/embedded/hare.xml new file mode 100644 index 0000000..c1f7e94 --- /dev/null +++ b/internal/chromalexers/embedded/hare.xml @@ -0,0 +1,98 @@ + + + Hare + hare + *.ha + text/x-hare + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/haskell.xml b/internal/chromalexers/embedded/haskell.xml new file mode 100644 index 0000000..1fad082 --- /dev/null +++ b/internal/chromalexers/embedded/haskell.xml @@ -0,0 +1,275 @@ + + + Haskell + haskell + hs + *.hs + text/x-haskell + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/hlsl.xml b/internal/chromalexers/embedded/hlsl.xml new file mode 100644 index 0000000..41ab323 --- /dev/null +++ b/internal/chromalexers/embedded/hlsl.xml @@ -0,0 +1,110 @@ + + + HLSL + hlsl + *.hlsl + *.hlsli + *.cginc + *.fx + *.fxh + text/x-hlsl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/idris.xml b/internal/chromalexers/embedded/idris.xml new file mode 100644 index 0000000..9592d88 --- /dev/null +++ b/internal/chromalexers/embedded/idris.xml @@ -0,0 +1,216 @@ + + + Idris + idris + idr + *.idr + text/x-idris + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/ini.xml b/internal/chromalexers/embedded/ini.xml new file mode 100644 index 0000000..3f1de09 --- /dev/null +++ b/internal/chromalexers/embedded/ini.xml @@ -0,0 +1,52 @@ + + + INI + ini + cfg + dosini + *.ini + *.cfg + *.inf + *.service + *.socket + *.container + *.network + *.build + *.pod + *.kube + *.volume + *.image + .gitconfig + .editorconfig + pylintrc + .pylintrc + text/x-ini + text/inf + 0.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/java.xml b/internal/chromalexers/embedded/java.xml new file mode 100644 index 0000000..4ef5fd2 --- /dev/null +++ b/internal/chromalexers/embedded/java.xml @@ -0,0 +1,206 @@ + + + Java + java + *.java + text/x-java + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/javascript.xml b/internal/chromalexers/embedded/javascript.xml new file mode 100644 index 0000000..0e475c5 --- /dev/null +++ b/internal/chromalexers/embedded/javascript.xml @@ -0,0 +1,160 @@ + + + JavaScript + js + javascript + *.js + *.jsm + *.mjs + *.cjs + application/javascript + application/x-javascript + text/x-javascript + text/javascript + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/json.xml b/internal/chromalexers/embedded/json.xml new file mode 100644 index 0000000..f65d710 --- /dev/null +++ b/internal/chromalexers/embedded/json.xml @@ -0,0 +1,117 @@ + + + JSON + json + jsonl + *.json + *.jsonl + *.jsonc + *.json5 + *.avsc + .luaurc + application/json + application/jsonl + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/julia.xml b/internal/chromalexers/embedded/julia.xml new file mode 100644 index 0000000..776dcdb --- /dev/null +++ b/internal/chromalexers/embedded/julia.xml @@ -0,0 +1,400 @@ + + + Julia + julia + jl + *.jl + text/x-julia + application/x-julia + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/kotlin.xml b/internal/chromalexers/embedded/kotlin.xml new file mode 100644 index 0000000..28bf2d8 --- /dev/null +++ b/internal/chromalexers/embedded/kotlin.xml @@ -0,0 +1,234 @@ + + + Kotlin + kotlin + *.kt + *.kts + text/x-kotlin + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/lean.xml b/internal/chromalexers/embedded/lean.xml new file mode 100644 index 0000000..6ac5151 --- /dev/null +++ b/internal/chromalexers/embedded/lean.xml @@ -0,0 +1,56 @@ + + + Lean4 + lean4 + lean + *.lean + text/x-lean4 + text/x-lean + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/llvm.xml b/internal/chromalexers/embedded/llvm.xml new file mode 100644 index 0000000..f24f152 --- /dev/null +++ b/internal/chromalexers/embedded/llvm.xml @@ -0,0 +1,73 @@ + + + LLVM + llvm + *.ll + text/x-llvm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/lua.xml b/internal/chromalexers/embedded/lua.xml new file mode 100644 index 0000000..903d458 --- /dev/null +++ b/internal/chromalexers/embedded/lua.xml @@ -0,0 +1,158 @@ + + + Lua + lua + *.lua + *.wlua + text/x-lua + application/x-lua + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/makefile.xml b/internal/chromalexers/embedded/makefile.xml new file mode 100644 index 0000000..a82a7f8 --- /dev/null +++ b/internal/chromalexers/embedded/makefile.xml @@ -0,0 +1,131 @@ + + + Makefile + make + makefile + mf + bsdmake + *.mak + *.mk + Makefile + makefile + Makefile.* + GNUmakefile + BSDmakefile + Justfile + justfile + .justfile + text/x-makefile + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/metal.xml b/internal/chromalexers/embedded/metal.xml new file mode 100644 index 0000000..62d04ba --- /dev/null +++ b/internal/chromalexers/embedded/metal.xml @@ -0,0 +1,270 @@ + + + Metal + metal + *.metal + text/x-metal + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/mlir.xml b/internal/chromalexers/embedded/mlir.xml new file mode 100644 index 0000000..025c3dc --- /dev/null +++ b/internal/chromalexers/embedded/mlir.xml @@ -0,0 +1,73 @@ + + + MLIR + mlir + *.mlir + text/x-mlir + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/modelica.xml b/internal/chromalexers/embedded/modelica.xml new file mode 100644 index 0000000..e5fa60f --- /dev/null +++ b/internal/chromalexers/embedded/modelica.xml @@ -0,0 +1,106 @@ + + + Modelica + modelica + *.mo + text/x-modelica + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/modula-2.xml b/internal/chromalexers/embedded/modula-2.xml new file mode 100644 index 0000000..0bf37bc --- /dev/null +++ b/internal/chromalexers/embedded/modula-2.xml @@ -0,0 +1,245 @@ + + + Modula-2 + modula2 + m2 + *.def + *.mod + text/x-modula2 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/mojo.xml b/internal/chromalexers/embedded/mojo.xml new file mode 100644 index 0000000..677811e --- /dev/null +++ b/internal/chromalexers/embedded/mojo.xml @@ -0,0 +1,228 @@ + + + Mojo + mojo + 🔥 + *.mojo + *.🔥 + text/x-mojo + application/x-mojo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/nasm.xml b/internal/chromalexers/embedded/nasm.xml new file mode 100644 index 0000000..defe65b --- /dev/null +++ b/internal/chromalexers/embedded/nasm.xml @@ -0,0 +1,126 @@ + + + NASM + nasm + *.asm + *.ASM + *.nasm + text/x-nasm + true + 1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/nim.xml b/internal/chromalexers/embedded/nim.xml new file mode 100644 index 0000000..bfdd615 --- /dev/null +++ b/internal/chromalexers/embedded/nim.xml @@ -0,0 +1,211 @@ + + + Nim + nim + nimrod + *.nim + *.nimrod + text/x-nim + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/objective-c.xml b/internal/chromalexers/embedded/objective-c.xml new file mode 100644 index 0000000..0dc9328 --- /dev/null +++ b/internal/chromalexers/embedded/objective-c.xml @@ -0,0 +1,510 @@ + + + Objective-C + objective-c + objectivec + obj-c + objc + *.m + *.h + text/x-objective-c + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/objectpascal.xml b/internal/chromalexers/embedded/objectpascal.xml new file mode 100644 index 0000000..0b72131 --- /dev/null +++ b/internal/chromalexers/embedded/objectpascal.xml @@ -0,0 +1,142 @@ + + + ObjectPascal + objectpascal + *.pas + *.pp + *.inc + *.dpr + *.dpk + *.lpr + *.lpk + text/x-pascal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/ocaml.xml b/internal/chromalexers/embedded/ocaml.xml new file mode 100644 index 0000000..1770d1d --- /dev/null +++ b/internal/chromalexers/embedded/ocaml.xml @@ -0,0 +1,153 @@ + + + OCaml + ocaml + *.ml + *.mli + *.mll + *.mly + text/x-ocaml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/odin.xml b/internal/chromalexers/embedded/odin.xml new file mode 100644 index 0000000..8a52949 --- /dev/null +++ b/internal/chromalexers/embedded/odin.xml @@ -0,0 +1,127 @@ + + + Odin + odin + *.odin + text/odin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/perl.xml b/internal/chromalexers/embedded/perl.xml new file mode 100644 index 0000000..8ac02ab --- /dev/null +++ b/internal/chromalexers/embedded/perl.xml @@ -0,0 +1,400 @@ + + + Perl + perl + pl + *.pl + *.pm + *.t + text/x-perl + application/x-perl + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/plaintext.xml b/internal/chromalexers/embedded/plaintext.xml new file mode 100644 index 0000000..d5e3243 --- /dev/null +++ b/internal/chromalexers/embedded/plaintext.xml @@ -0,0 +1,21 @@ + + + plaintext + text + plain + no-highlight + *.txt + text/plain + -1 + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/pony.xml b/internal/chromalexers/embedded/pony.xml new file mode 100644 index 0000000..4efa9db --- /dev/null +++ b/internal/chromalexers/embedded/pony.xml @@ -0,0 +1,135 @@ + + + Pony + pony + *.pony + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/protocol_buffer.xml b/internal/chromalexers/embedded/protocol_buffer.xml new file mode 100644 index 0000000..f8f0c8d --- /dev/null +++ b/internal/chromalexers/embedded/protocol_buffer.xml @@ -0,0 +1,124 @@ + + + Protocol Buffer + protobuf + proto + *.proto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/python.xml b/internal/chromalexers/embedded/python.xml new file mode 100644 index 0000000..eaa9c30 --- /dev/null +++ b/internal/chromalexers/embedded/python.xml @@ -0,0 +1,595 @@ + + + Python + python + py + sage + python3 + py3 + starlark + *.py + *.pyi + *.pyw + *.jy + *.sage + *.sc + SConstruct + SConscript + *.bzl + BUCK + BUILD + BUILD.bazel + WORKSPACE + WORKSPACE.bzlmod + WORKSPACE.bazel + MODULE.bazel + REPO.bazel + *.star + *.tac + text/x-python + application/x-python + text/x-python3 + application/x-python3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/reasonml.xml b/internal/chromalexers/embedded/reasonml.xml new file mode 100644 index 0000000..8b7bcc5 --- /dev/null +++ b/internal/chromalexers/embedded/reasonml.xml @@ -0,0 +1,147 @@ + + + ReasonML + reason + reasonml + *.re + *.rei + text/x-reasonml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/ruby.xml b/internal/chromalexers/embedded/ruby.xml new file mode 100644 index 0000000..15e96ba --- /dev/null +++ b/internal/chromalexers/embedded/ruby.xml @@ -0,0 +1,728 @@ + + + Ruby + rb + ruby + duby + *.rb + *.rbw + Rakefile + *.rake + *.gemspec + *.rbx + *.duby + Gemfile + *.gemfile + Vagrantfile + Appraisals + .pryrc + *.json.jbuilder + text/x-ruby + application/x-ruby + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/rust.xml b/internal/chromalexers/embedded/rust.xml new file mode 100644 index 0000000..083b96f --- /dev/null +++ b/internal/chromalexers/embedded/rust.xml @@ -0,0 +1,375 @@ + + + Rust + rust + rs + *.rs + *.rs.in + text/rust + text/x-rust + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/scala.xml b/internal/chromalexers/embedded/scala.xml new file mode 100644 index 0000000..2f8ddd4 --- /dev/null +++ b/internal/chromalexers/embedded/scala.xml @@ -0,0 +1,274 @@ + + + Scala + scala + *.scala + text/x-scala + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/sql.xml b/internal/chromalexers/embedded/sql.xml new file mode 100644 index 0000000..b542b65 --- /dev/null +++ b/internal/chromalexers/embedded/sql.xml @@ -0,0 +1,90 @@ + + + SQL + sql + *.sql + text/x-sql + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/standard_ml.xml b/internal/chromalexers/embedded/standard_ml.xml new file mode 100644 index 0000000..39cf4f2 --- /dev/null +++ b/internal/chromalexers/embedded/standard_ml.xml @@ -0,0 +1,548 @@ + + + Standard ML + sml + *.sml + *.sig + *.fun + text/x-standardml + application/x-standardml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/swift.xml b/internal/chromalexers/embedded/swift.xml new file mode 100644 index 0000000..416bf90 --- /dev/null +++ b/internal/chromalexers/embedded/swift.xml @@ -0,0 +1,207 @@ + + + Swift + swift + *.swift + text/x-swift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/systemverilog.xml b/internal/chromalexers/embedded/systemverilog.xml new file mode 100644 index 0000000..fac3da2 --- /dev/null +++ b/internal/chromalexers/embedded/systemverilog.xml @@ -0,0 +1,181 @@ + + + systemverilog + systemverilog + sv + *.sv + *.svh + text/x-systemverilog + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/toml.xml b/internal/chromalexers/embedded/toml.xml new file mode 100644 index 0000000..87bd19d --- /dev/null +++ b/internal/chromalexers/embedded/toml.xml @@ -0,0 +1,45 @@ + + + TOML + toml + *.toml + Pipfile + poetry.lock + uv.lock + text/x-toml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/typescript.xml b/internal/chromalexers/embedded/typescript.xml new file mode 100644 index 0000000..b39c964 --- /dev/null +++ b/internal/chromalexers/embedded/typescript.xml @@ -0,0 +1,302 @@ + + + TypeScript + ts + tsx + typescript + *.ts + *.tsx + *.mts + *.cts + text/x-typescript + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/v.xml b/internal/chromalexers/embedded/v.xml new file mode 100644 index 0000000..e1af3d1 --- /dev/null +++ b/internal/chromalexers/embedded/v.xml @@ -0,0 +1,355 @@ + + + V + v + vlang + *.v + *.vv + v.mod + text/x-v + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/vala.xml b/internal/chromalexers/embedded/vala.xml new file mode 100644 index 0000000..17c1acf --- /dev/null +++ b/internal/chromalexers/embedded/vala.xml @@ -0,0 +1,72 @@ + + + + Vala + vala + vapi + *.vala + *.vapi + text/x-vala + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/embedded/vb_net.xml b/internal/chromalexers/embedded/vb_net.xml new file mode 100644 index 0000000..9f85afd --- /dev/null +++ b/internal/chromalexers/embedded/vb_net.xml @@ -0,0 +1,162 @@ + + + VB.net + vb.net + vbnet + *.vb + *.bas + text/x-vbnet + text/x-vba + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/vhdl.xml b/internal/chromalexers/embedded/vhdl.xml new file mode 100644 index 0000000..aa42044 --- /dev/null +++ b/internal/chromalexers/embedded/vhdl.xml @@ -0,0 +1,171 @@ + + + VHDL + vhdl + *.vhdl + *.vhd + text/x-vhdl + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/xml.xml b/internal/chromalexers/embedded/xml.xml new file mode 100644 index 0000000..99d5d53 --- /dev/null +++ b/internal/chromalexers/embedded/xml.xml @@ -0,0 +1,96 @@ + + + XML + xml + *.xml + *.xsl + *.rss + *.xslt + *.xsd + *.wsdl + *.wsf + *.svg + *.qrc + *.csproj + *.vcxproj + *.fsproj + text/xml + application/xml + image/svg+xml + application/rss+xml + application/atom+xml + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/yaml.xml b/internal/chromalexers/embedded/yaml.xml new file mode 100644 index 0000000..ede15d3 --- /dev/null +++ b/internal/chromalexers/embedded/yaml.xml @@ -0,0 +1,132 @@ + + + YAML + yaml + *.yaml + *.yml + text/x-yaml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/chromalexers/embedded/zig.xml b/internal/chromalexers/embedded/zig.xml new file mode 100644 index 0000000..5617f91 --- /dev/null +++ b/internal/chromalexers/embedded/zig.xml @@ -0,0 +1,187 @@ + + + Zig + zig + *.zig + *.zon + text/zig + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/chromalexers/go.go b/internal/chromalexers/go.go new file mode 100644 index 0000000..5cdc29a --- /dev/null +++ b/internal/chromalexers/go.go @@ -0,0 +1,71 @@ +package chromalexers + +import ( + "strings" + + "github.com/alecthomas/chroma/v2" +) + +// registerGo keeps Chroma's hand-written Go lexer in the curated registry +// without importing github.com/alecthomas/chroma/v2/lexers, which embeds the full +// upstream lexer set. +func registerGo(reg *chroma.LexerRegistry) { + reg.Register(chroma.MustNewLexer( + &chroma.Config{ + Name: "Go", + Aliases: []string{"go", "golang"}, + Filenames: []string{"*.go"}, + MimeTypes: []string{"text/x-gosrc"}, + }, + goRules, + ).SetAnalyser(func(text string) float32 { + if strings.Contains(text, "fmt.") && strings.Contains(text, "package ") { + return 0.5 + } + if strings.Contains(text, "package ") { + return 0.1 + } + return 0 + })) +} + +// rule builds a keyed chroma.Rule (the upstream lexer uses positional literals, +// which go vet rejects for out-of-package struct types). +func rule(pattern string, emitter chroma.Emitter) chroma.Rule { + return chroma.Rule{Pattern: pattern, Type: emitter} +} + +func goRules() chroma.Rules { + return chroma.Rules{ + "root": { + rule(`\n`, chroma.TextWhitespace), + rule(`\s+`, chroma.TextWhitespace), + rule(`//[^\s\n\r][^\n\r]*`, chroma.CommentPreproc), + rule(`//[^\n\r]*`, chroma.CommentSingle), + rule(`/(\\\n)?[*](.|\n)*?[*](\\\n)?/`, chroma.CommentMultiline), + rule(`(import|package)\b`, chroma.KeywordNamespace), + rule(`(var|func|struct|map|chan|type|interface|const)\b`, chroma.KeywordDeclaration), + rule(chroma.Words(``, `\b`, `break`, `default`, `select`, `case`, `defer`, `go`, `else`, `goto`, `switch`, `fallthrough`, `if`, `range`, `continue`, `for`, `return`), chroma.Keyword), + rule(`(true|false|iota|nil)\b`, chroma.KeywordConstant), + rule(chroma.Words(``, `\b(\()`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `int`, `int8`, `int16`, `int32`, `int64`, `float`, `float32`, `float64`, `complex64`, `complex128`, `byte`, `rune`, `string`, `bool`, `error`, `uintptr`, `print`, `println`, `panic`, `recover`, `close`, `complex`, `real`, `imag`, `len`, `cap`, `append`, `copy`, `delete`, `new`, `make`, `clear`, `min`, `max`), chroma.ByGroups(chroma.NameBuiltin, chroma.Punctuation)), + rule(chroma.Words(``, `\b`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `int`, `int8`, `int16`, `int32`, `int64`, `float`, `float32`, `float64`, `complex64`, `complex128`, `byte`, `rune`, `string`, `bool`, `error`, `uintptr`, `any`), chroma.KeywordType), + rule(`\d+i`, chroma.LiteralNumber), + rule(`\d+\.\d*([Ee][-+]\d+)?i`, chroma.LiteralNumber), + rule(`\.\d+([Ee][-+]\d+)?i`, chroma.LiteralNumber), + rule(`\d+[Ee][-+]\d+i`, chroma.LiteralNumber), + rule(`\d+(\.\d+[eE][+\-]?\d+|\.\d*|[eE][+\-]?\d+)`, chroma.LiteralNumberFloat), + rule(`\.\d+([eE][+\-]?\d+)?`, chroma.LiteralNumberFloat), + rule(`0[0-7]+`, chroma.LiteralNumberOct), + rule(`0[xX][0-9a-fA-F_]+`, chroma.LiteralNumberHex), + rule(`0b[01_]+`, chroma.LiteralNumberBin), + rule(`(0|[1-9][0-9_]*)`, chroma.LiteralNumberInteger), + rule(`'(\\['"\\abfnrtv]|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8}|[^\\])'`, chroma.LiteralStringChar), + rule("`[^`]*`", chroma.LiteralStringBacktick), + rule(`"(\\\\|\\"|[^"])*"`, chroma.LiteralString), + rule(`(<<=|>>=|<<|>>|<=|>=|&\^=|&\^|\+=|-=|\*=|/=|%=|&=|\|=|&&|\|\||<-|\+\+|--|==|!=|:=|\.\.\.|[+\-*/%&])`, chroma.Operator), + rule(`([a-zA-Z_]\w*)(\s*)(\()`, chroma.ByGroups(chroma.NameFunction, chroma.UsingSelf("root"), chroma.Punctuation)), + rule(`[|^<>=!()\[\]{}.,;:~]`, chroma.Punctuation), + rule(`[^\W\d]\w*`, chroma.NameOther), + }, + } +} diff --git a/internal/chromalexers/lexers.go b/internal/chromalexers/lexers.go new file mode 100644 index 0000000..61a3828 --- /dev/null +++ b/internal/chromalexers/lexers.go @@ -0,0 +1,78 @@ +// Package chromalexers provides the curated Chroma lexer registry bundled by +// exex's default build. +package chromalexers + +import ( + "embed" + "io/fs" + "path/filepath" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" +) + +//go:embed embedded/*.xml +var embedded embed.FS + +// registry builds the curated lexer registry on first use. Lazy so that runs +// that never highlight (most `-o` dumps) skip parsing the embedded XML configs +// and compiling their analyser regexes at startup. +var registry = sync.OnceValue(func() *chroma.LexerRegistry { + reg := chroma.NewLexerRegistry() + paths, err := fs.Glob(embedded, "embedded/*.xml") + if err != nil { + panic(err) + } + for _, path := range paths { + reg.Register(chroma.MustNewXMLLexer(embedded, path)) + } + registerGo(reg) + return reg +}) + +// Names returns the curated lexer names, optionally including aliases. +func Names(withAliases bool) []string { + return registry().Names(withAliases) +} + +// Get returns a curated lexer by name, alias, extension, or filename. +func Get(name string) chroma.Lexer { + return registry().Get(name) +} + +// Match returns the first curated lexer matching filename. +func Match(filename string) chroma.Lexer { + base := filepath.Base(filename) + if base == "v.mod" { + if l := Get("v"); l != nil { + return l + } + } + if strings.HasSuffix(base, ".s") || strings.HasSuffix(base, ".S") { + if l := Get("gas"); l != nil { + return l + } + } + // OpenCL C is a C superset. Chroma has no OpenCL lexer, and .cl otherwise + // matches Common Lisp — which never shows up in ELF/Mach-O DWARF — so route + // OpenCL kernels (.cl) to the C lexer for correct-enough highlighting. + if strings.HasSuffix(base, ".cl") { + if l := Get("c"); l != nil { + return l + } + } + // Objective-C++ (.mm) has no dedicated Chroma lexer; the Objective-C lexer is + // the closest fit (Objective-C++ is Objective-C plus C++). + if strings.HasSuffix(base, ".mm") { + if l := Get("objective-c"); l != nil { + return l + } + } + return registry().Match(filename) +} + +// Analyse chooses the best curated lexer for text content. +func Analyse(text string) chroma.Lexer { + return registry().Analyse(text) +} diff --git a/internal/chromalexers/lexers_test.go b/internal/chromalexers/lexers_test.go new file mode 100644 index 0000000..ca8c395 --- /dev/null +++ b/internal/chromalexers/lexers_test.go @@ -0,0 +1,91 @@ +package chromalexers + +import "testing" + +func TestCuratedLexers(t *testing.T) { + for _, name := range []string{ + "Ada", "Arduino", "ArmAsm", "Ballerina", "Beef", "C", "C++", "C#", "C3", "Chapel", "Clojure", "COBOL", + "Crystal", "Cython", "D", "Dart", "Dylan", "FSharp", "Fortran", "FortranFixed", "GAS", "GLSL", "Go", "golang", + "Groovy", "Hare", "Haskell", "HLSL", "Idris", "Java", "Julia", "Kotlin", "Lean4", "LLVM", "Metal", "MLIR", + "Modelica", "Modula-2", "Mojo", "NASM", "Nim", "ObjectPascal", "Objective-C", "OCaml", "Odin", "Pony", + "Python", "ReasonML", "Rust", "Scala", "Standard ML", "Swift", "systemverilog", "V", "Vala", "VB.net", "VHDL", "YAML", "Zig", + } { + if l := Get(name); l == nil { + t.Fatalf("Get(%q) returned nil", name) + } + } +} + +func TestCuratedLexerMatching(t *testing.T) { + tests := map[string]string{ + "app.bal": "Ballerina", + "board.ino": "Arduino", + "calc.bf": "Beef", + "class.dylan": "Dylan", + "kernel.chpl": "Chapel", + "kernel.metal": "Metal", + "main.adb": "Ada", + "main.c": "C", + "main.c3": "C3", + "main.clj": "Clojure", + "main.cob": "COBOL", + "main.d": "D", + "main.dart": "Dart", + "main.groovy": "Groovy", + "main.ha": "Hare", + "main.f90": "Fortran", + "main.go": "Go", + "main.hs": "Haskell", + "main.idr": "Idris", + "main.jl": "Julia", + "main.lean": "Lean4", + "main.ml": "OCaml", + "main.mo": "Modelica", + "main.mod": "Modula-2", + "main.mojo": "Mojo", + "main.nim": "Nim", + "main.odin": "Odin", + "main.pas": "ObjectPascal", + "main.pony": "Pony", + "main.pyx": "Cython", + "main.re": "ReasonML", + "main.rs": "Rust", + "main.sig": "Standard ML", + "main.sv": "systemverilog", + "main.v": "V", + "main.vala": "Vala", + "main.vb": "VB.net", + "main.vhdl": "VHDL", + "module.ll": "LLVM", + "module.mlir": "MLIR", + "v.mod": "V", + "CMakeLists.txt": "CMake", + "foo.s": "GAS", + "schema.proto": "Protocol Buffer", + } + for filename, want := range tests { + l := Match(filename) + if l == nil { + t.Fatalf("Match(%q) returned nil", filename) + } + if got := l.Config().Name; got != want { + t.Fatalf("Match(%q) = %q, want %q", filename, got, want) + } + } +} + +func TestCuratedLexerAnalyse(t *testing.T) { + l := Analyse("package main\n\nfunc main() { fmt.Println(\"hi\") }\n") + if l == nil { + t.Fatal("Analyse Go source returned nil") + } + if got := l.Config().Name; got != "Go" { + t.Fatalf("Analyse Go source = %q, want Go", got) + } +} + +func TestUnsupportedLexerIsAbsent(t *testing.T) { + if l := Get("elixir"); l != nil { + t.Fatalf("Get(elixir) = %q, want nil", l.Config().Name) + } +} diff --git a/internal/chromalexers/using_test.go b/internal/chromalexers/using_test.go new file mode 100644 index 0000000..d6178a8 --- /dev/null +++ b/internal/chromalexers/using_test.go @@ -0,0 +1,36 @@ +package chromalexers + +import ( + "io/fs" + "regexp" + "testing" +) + +// usingRef matches a static cross-lexer delegation, e.g. . +var usingRef = regexp.MustCompile(`using\s+lexer="([^"]+)"`) + +// TestUsingGraphIsClosed guards the curated lexer set against dangling +// delegations. A lexer XML can hand a sub-region to another lexer by name +// (); Chroma resolves X against the same registry at tokenise +// time and *panics* if it is absent. So dropping a lexer from lexers.txt that +// another still references (as removing CSS would have broken HTML) turns into a +// crash on one file type — this test turns that into a build-time failure. +func TestUsingGraphIsClosed(t *testing.T) { + paths, err := fs.Glob(embedded, "embedded/*.xml") + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + data, err := fs.ReadFile(embedded, path) + if err != nil { + t.Fatal(err) + } + for _, m := range usingRef.FindAllStringSubmatch(string(data), -1) { + ref := m[1] + if Get(ref) == nil { + t.Errorf("%s delegates to lexer %q, which is not in the curated set "+ + "(add %q to internal/chromasubset/lexers.txt, or it will panic at runtime)", path, ref, ref) + } + } + } +} diff --git a/internal/chromastyles/embedded/abap.xml b/internal/chromastyles/embedded/abap.xml new file mode 100644 index 0000000..36ea2f1 --- /dev/null +++ b/internal/chromastyles/embedded/abap.xml @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/arduino.xml b/internal/chromastyles/embedded/arduino.xml new file mode 100644 index 0000000..d9891dc --- /dev/null +++ b/internal/chromastyles/embedded/arduino.xml @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/ashen.xml b/internal/chromastyles/embedded/ashen.xml new file mode 100644 index 0000000..c22b3fa --- /dev/null +++ b/internal/chromastyles/embedded/ashen.xml @@ -0,0 +1,69 @@ + + diff --git a/internal/chromastyles/embedded/aura-theme-dark.xml b/internal/chromastyles/embedded/aura-theme-dark.xml new file mode 100644 index 0000000..85e8ec9 --- /dev/null +++ b/internal/chromastyles/embedded/aura-theme-dark.xml @@ -0,0 +1,107 @@ + + + diff --git a/internal/chromastyles/embedded/base16-snazzy.xml b/internal/chromastyles/embedded/base16-snazzy.xml new file mode 100644 index 0000000..a05ba24 --- /dev/null +++ b/internal/chromastyles/embedded/base16-snazzy.xml @@ -0,0 +1,74 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/catppuccin-frappe.xml b/internal/chromastyles/embedded/catppuccin-frappe.xml new file mode 100644 index 0000000..66a361f --- /dev/null +++ b/internal/chromastyles/embedded/catppuccin-frappe.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/catppuccin-latte.xml b/internal/chromastyles/embedded/catppuccin-latte.xml new file mode 100644 index 0000000..a6e41ab --- /dev/null +++ b/internal/chromastyles/embedded/catppuccin-latte.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/catppuccin-macchiato.xml b/internal/chromastyles/embedded/catppuccin-macchiato.xml new file mode 100644 index 0000000..5dba9c6 --- /dev/null +++ b/internal/chromastyles/embedded/catppuccin-macchiato.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/catppuccin-mocha.xml b/internal/chromastyles/embedded/catppuccin-mocha.xml new file mode 100644 index 0000000..46160da --- /dev/null +++ b/internal/chromastyles/embedded/catppuccin-mocha.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/darcula.xml b/internal/chromastyles/embedded/darcula.xml new file mode 100644 index 0000000..4c35506 --- /dev/null +++ b/internal/chromastyles/embedded/darcula.xml @@ -0,0 +1,83 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/doom-one.xml b/internal/chromastyles/embedded/doom-one.xml new file mode 100644 index 0000000..1f5127e --- /dev/null +++ b/internal/chromastyles/embedded/doom-one.xml @@ -0,0 +1,51 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/doom-one2.xml b/internal/chromastyles/embedded/doom-one2.xml new file mode 100644 index 0000000..f47deba --- /dev/null +++ b/internal/chromastyles/embedded/doom-one2.xml @@ -0,0 +1,64 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/dracula.xml b/internal/chromastyles/embedded/dracula.xml new file mode 100644 index 0000000..9df7da1 --- /dev/null +++ b/internal/chromastyles/embedded/dracula.xml @@ -0,0 +1,74 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/evergarden.xml b/internal/chromastyles/embedded/evergarden.xml new file mode 100644 index 0000000..da1d9b8 --- /dev/null +++ b/internal/chromastyles/embedded/evergarden.xml @@ -0,0 +1,33 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/fruity.xml b/internal/chromastyles/embedded/fruity.xml new file mode 100644 index 0000000..bcc06aa --- /dev/null +++ b/internal/chromastyles/embedded/fruity.xml @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/github-dark.xml b/internal/chromastyles/embedded/github-dark.xml new file mode 100644 index 0000000..9f2b7e1 --- /dev/null +++ b/internal/chromastyles/embedded/github-dark.xml @@ -0,0 +1,46 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/gruvbox-light.xml b/internal/chromastyles/embedded/gruvbox-light.xml new file mode 100644 index 0000000..3d03c96 --- /dev/null +++ b/internal/chromastyles/embedded/gruvbox-light.xml @@ -0,0 +1,34 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/gruvbox.xml b/internal/chromastyles/embedded/gruvbox.xml new file mode 100644 index 0000000..f057c46 --- /dev/null +++ b/internal/chromastyles/embedded/gruvbox.xml @@ -0,0 +1,34 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/kanagawa-dragon.xml b/internal/chromastyles/embedded/kanagawa-dragon.xml new file mode 100644 index 0000000..114d165 --- /dev/null +++ b/internal/chromastyles/embedded/kanagawa-dragon.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/kanagawa-lotus.xml b/internal/chromastyles/embedded/kanagawa-lotus.xml new file mode 100644 index 0000000..622ae4e --- /dev/null +++ b/internal/chromastyles/embedded/kanagawa-lotus.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/kanagawa-wave.xml b/internal/chromastyles/embedded/kanagawa-wave.xml new file mode 100644 index 0000000..bc9b646 --- /dev/null +++ b/internal/chromastyles/embedded/kanagawa-wave.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/modus-operandi.xml b/internal/chromastyles/embedded/modus-operandi.xml new file mode 100644 index 0000000..066efa7 --- /dev/null +++ b/internal/chromastyles/embedded/modus-operandi.xml @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/modus-vivendi.xml b/internal/chromastyles/embedded/modus-vivendi.xml new file mode 100644 index 0000000..640f831 --- /dev/null +++ b/internal/chromastyles/embedded/modus-vivendi.xml @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/monokai.xml b/internal/chromastyles/embedded/monokai.xml new file mode 100644 index 0000000..6734eda --- /dev/null +++ b/internal/chromastyles/embedded/monokai.xml @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/monokailight.xml b/internal/chromastyles/embedded/monokailight.xml new file mode 100644 index 0000000..4018623 --- /dev/null +++ b/internal/chromastyles/embedded/monokailight.xml @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/native.xml b/internal/chromastyles/embedded/native.xml new file mode 100644 index 0000000..43eea7f --- /dev/null +++ b/internal/chromastyles/embedded/native.xml @@ -0,0 +1,35 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/nord.xml b/internal/chromastyles/embedded/nord.xml new file mode 100644 index 0000000..1c1d1ff --- /dev/null +++ b/internal/chromastyles/embedded/nord.xml @@ -0,0 +1,46 @@ + diff --git a/internal/chromastyles/embedded/nordic.xml b/internal/chromastyles/embedded/nordic.xml new file mode 100644 index 0000000..4c36b8e --- /dev/null +++ b/internal/chromastyles/embedded/nordic.xml @@ -0,0 +1,46 @@ + diff --git a/internal/chromastyles/embedded/onedark.xml b/internal/chromastyles/embedded/onedark.xml new file mode 100644 index 0000000..6921eb5 --- /dev/null +++ b/internal/chromastyles/embedded/onedark.xml @@ -0,0 +1,25 @@ + diff --git a/internal/chromastyles/embedded/paraiso-dark.xml b/internal/chromastyles/embedded/paraiso-dark.xml new file mode 100644 index 0000000..1064c61 --- /dev/null +++ b/internal/chromastyles/embedded/paraiso-dark.xml @@ -0,0 +1,37 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/paraiso-light.xml b/internal/chromastyles/embedded/paraiso-light.xml new file mode 100644 index 0000000..04fcc88 --- /dev/null +++ b/internal/chromastyles/embedded/paraiso-light.xml @@ -0,0 +1,37 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/rainbow_dash.xml b/internal/chromastyles/embedded/rainbow_dash.xml new file mode 100644 index 0000000..5b0fe49 --- /dev/null +++ b/internal/chromastyles/embedded/rainbow_dash.xml @@ -0,0 +1,40 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/rose-pine-dawn.xml b/internal/chromastyles/embedded/rose-pine-dawn.xml new file mode 100644 index 0000000..b200171 --- /dev/null +++ b/internal/chromastyles/embedded/rose-pine-dawn.xml @@ -0,0 +1,29 @@ + diff --git a/internal/chromastyles/embedded/rose-pine-moon.xml b/internal/chromastyles/embedded/rose-pine-moon.xml new file mode 100644 index 0000000..f67b804 --- /dev/null +++ b/internal/chromastyles/embedded/rose-pine-moon.xml @@ -0,0 +1,29 @@ + diff --git a/internal/chromastyles/embedded/rose-pine.xml b/internal/chromastyles/embedded/rose-pine.xml new file mode 100644 index 0000000..ed15cb8 --- /dev/null +++ b/internal/chromastyles/embedded/rose-pine.xml @@ -0,0 +1,29 @@ + diff --git a/internal/chromastyles/embedded/rpgle.xml b/internal/chromastyles/embedded/rpgle.xml new file mode 100644 index 0000000..678fd70 --- /dev/null +++ b/internal/chromastyles/embedded/rpgle.xml @@ -0,0 +1,30 @@ + diff --git a/internal/chromastyles/embedded/rrt.xml b/internal/chromastyles/embedded/rrt.xml new file mode 100644 index 0000000..f2c5feb --- /dev/null +++ b/internal/chromastyles/embedded/rrt.xml @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/solarized-dark.xml b/internal/chromastyles/embedded/solarized-dark.xml new file mode 100644 index 0000000..48faa26 --- /dev/null +++ b/internal/chromastyles/embedded/solarized-dark.xml @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/solarized-dark256.xml b/internal/chromastyles/embedded/solarized-dark256.xml new file mode 100644 index 0000000..977cfbe --- /dev/null +++ b/internal/chromastyles/embedded/solarized-dark256.xml @@ -0,0 +1,41 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/solarized-light.xml b/internal/chromastyles/embedded/solarized-light.xml new file mode 100644 index 0000000..6d74b40 --- /dev/null +++ b/internal/chromastyles/embedded/solarized-light.xml @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/swapoff.xml b/internal/chromastyles/embedded/swapoff.xml new file mode 100644 index 0000000..8a398df --- /dev/null +++ b/internal/chromastyles/embedded/swapoff.xml @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/tango.xml b/internal/chromastyles/embedded/tango.xml new file mode 100644 index 0000000..27f9b4b --- /dev/null +++ b/internal/chromastyles/embedded/tango.xml @@ -0,0 +1,72 @@ + diff --git a/internal/chromastyles/embedded/tokyonight-day.xml b/internal/chromastyles/embedded/tokyonight-day.xml new file mode 100644 index 0000000..6b8d31e --- /dev/null +++ b/internal/chromastyles/embedded/tokyonight-day.xml @@ -0,0 +1,83 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/tokyonight-moon.xml b/internal/chromastyles/embedded/tokyonight-moon.xml new file mode 100644 index 0000000..3312f02 --- /dev/null +++ b/internal/chromastyles/embedded/tokyonight-moon.xml @@ -0,0 +1,83 @@ + diff --git a/internal/chromastyles/embedded/tokyonight-night.xml b/internal/chromastyles/embedded/tokyonight-night.xml new file mode 100644 index 0000000..35ef3f5 --- /dev/null +++ b/internal/chromastyles/embedded/tokyonight-night.xml @@ -0,0 +1,83 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/vulcan.xml b/internal/chromastyles/embedded/vulcan.xml new file mode 100644 index 0000000..4e69094 --- /dev/null +++ b/internal/chromastyles/embedded/vulcan.xml @@ -0,0 +1,74 @@ + diff --git a/internal/chromastyles/embedded/witchhazel.xml b/internal/chromastyles/embedded/witchhazel.xml new file mode 100644 index 0000000..52f2299 --- /dev/null +++ b/internal/chromastyles/embedded/witchhazel.xml @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/xcode-dark.xml b/internal/chromastyles/embedded/xcode-dark.xml new file mode 100644 index 0000000..f091d17 --- /dev/null +++ b/internal/chromastyles/embedded/xcode-dark.xml @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/internal/chromastyles/embedded/xcode.xml b/internal/chromastyles/embedded/xcode.xml new file mode 100644 index 0000000..7c00769 --- /dev/null +++ b/internal/chromastyles/embedded/xcode.xml @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/internal/chromastyles/styles.go b/internal/chromastyles/styles.go new file mode 100644 index 0000000..5a1765c --- /dev/null +++ b/internal/chromastyles/styles.go @@ -0,0 +1,55 @@ +// Package chromastyles provides the curated Chroma style registry bundled by +// exex's default build. +package chromastyles + +import ( + "embed" + "io/fs" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" +) + +//go:embed embedded/*.xml +var embedded embed.FS + +// registry parses the curated styles on first use. Lazy so that runs that never +// highlight (most `-o` dumps) skip parsing the embedded XML at startup. +var registry = sync.OnceValue(func() map[string]*chroma.Style { + reg := map[string]*chroma.Style{} + files, err := fs.ReadDir(embedded, "embedded") + if err != nil { + panic(err) + } + for _, file := range files { + if file.IsDir() { + continue + } + r, err := embedded.Open("embedded/" + file.Name()) + if err != nil { + panic(err) + } + style, err := chroma.NewXMLStyle(r) + _ = r.Close() + if err != nil { + panic(err) + } + reg[strings.ToLower(style.Name)] = style + } + return reg +}) + +// Fallback returns the curated fallback style. +func Fallback() *chroma.Style { + return registry()["swapoff"] +} + +// Lookup returns the named curated style and whether it is bundled. Callers +// decide what an absent style means; there is deliberately no Get-with-fallback +// helper, because silently substituting a different style is what let the +// settings picker offer themes it could not actually render. +func Lookup(name string) (*chroma.Style, bool) { + style, ok := registry()[strings.ToLower(strings.TrimSpace(name))] + return style, ok +} diff --git a/internal/chromastyles/styles_test.go b/internal/chromastyles/styles_test.go new file mode 100644 index 0000000..1aa14bb --- /dev/null +++ b/internal/chromastyles/styles_test.go @@ -0,0 +1,42 @@ +package chromastyles + +import ( + "testing" + + "github.com/rabarbra/exex/internal/chromasubset" +) + +func TestCuratedStyles(t *testing.T) { + for _, name := range []string{"swapoff", "nord", "catppuccin-mocha", "dracula", "solarized-dark", "solarized-light"} { + if st, ok := Lookup(name); !ok || st == nil { + t.Errorf("Lookup(%q) returned no style", name) + } + } +} + +func TestCuratedStyleFallback(t *testing.T) { + if Fallback() == nil { + t.Fatal("Fallback is nil") + } + if _, ok := Lookup("definitely-not-a-style"); ok { + t.Fatal("unknown style unexpectedly bundled") + } +} + +// TestBundledMatchesManifest keeps the embedded assets and the manifest in step. +// internal/theme's palette table is generated from the same manifest, so drift +// here is drift between the settings picker and the styles it can render. +func TestBundledMatchesManifest(t *testing.T) { + names, err := chromasubset.StyleNames() + if err != nil { + t.Fatalf("StyleNames: %v", err) + } + for _, name := range names { + if _, ok := Lookup(name); !ok { + t.Errorf("styles.txt lists %q but it is not embedded; run go generate ./internal/chromasubset", name) + } + } + if got := len(registry()); got != len(names) { + t.Errorf("embedded %d styles, manifest lists %d", got, len(names)) + } +} diff --git a/internal/chromasubset/chromasubset.go b/internal/chromasubset/chromasubset.go new file mode 100644 index 0000000..5bcd1b8 --- /dev/null +++ b/internal/chromasubset/chromasubset.go @@ -0,0 +1,79 @@ +// Package chromasubset owns the curated Chroma XML manifests bundled by exex. +// +// The manifests are the single source of truth for the curated set. Two +// generators read them: +// +// - gen/main.go copies the named XML assets into internal/chromalexers and +// internal/chromastyles, which embed them. +// - internal/theme/gen/main.go emits a palette entry for each curated style, +// so internal/theme never describes a theme whose highlighter is absent. +// +// Keeping both generators on one list is what stops the settings picker from +// offering themes that silently fall back to the minimal highlighter. +package chromasubset + +import ( + "bufio" + _ "embed" + "fmt" + "path" + "strings" +) + +//go:generate go run gen/main.go + +//go:embed styles.txt +var stylesManifest string + +//go:embed lexers.txt +var lexersManifest string + +// StyleAssets returns the curated style XML filenames in manifest order. +func StyleAssets() ([]string, error) { return parseManifest("styles.txt", stylesManifest) } + +// LexerAssets returns the curated lexer XML filenames in manifest order. +func LexerAssets() ([]string, error) { return parseManifest("lexers.txt", lexersManifest) } + +// StyleNames returns the curated Chroma style names — the asset filenames minus +// their ".xml" suffix. Chroma's own style names match their filenames; the +// theme generator verifies that rather than trusting it. +func StyleNames() ([]string, error) { + assets, err := StyleAssets() + if err != nil { + return nil, err + } + names := make([]string, len(assets)) + for i, a := range assets { + names[i] = strings.TrimSuffix(a, ".xml") + } + return names, nil +} + +// parseManifest reads one asset name per line, ignoring blanks and # comments, +// and rejects duplicates and anything that isn't a bare .xml filename. +func parseManifest(name, body string) ([]string, error) { + seen := map[string]bool{} + var assets []string + s := bufio.NewScanner(strings.NewReader(body)) + for line := 1; s.Scan(); line++ { + asset := strings.TrimSpace(s.Text()) + if asset == "" || strings.HasPrefix(asset, "#") { + continue + } + if path.Base(asset) != asset || !strings.HasSuffix(asset, ".xml") { + return nil, fmt.Errorf("%s:%d: invalid asset name %q", name, line, asset) + } + if seen[asset] { + return nil, fmt.Errorf("%s:%d: duplicate asset %q", name, line, asset) + } + seen[asset] = true + assets = append(assets, asset) + } + if err := s.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", name, err) + } + if len(assets) == 0 { + return nil, fmt.Errorf("%s has no assets", name) + } + return assets, nil +} diff --git a/internal/chromasubset/gen/main.go b/internal/chromasubset/gen/main.go new file mode 100644 index 0000000..99a532b --- /dev/null +++ b/internal/chromasubset/gen/main.go @@ -0,0 +1,98 @@ +//go:build ignore + +// Command gen copies the curated Chroma lexer/style XML manifests into the +// internal runtime registries. +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/rabarbra/exex/internal/chromasubset" +) + +func main() { + chromaDir, err := chromaModuleDir() + if err != nil { + fatal(err) + } + lexers, err := chromasubset.LexerAssets() + if err != nil { + fatal(err) + } + styles, err := chromasubset.StyleAssets() + if err != nil { + fatal(err) + } + if err := copyAssets("lexers.txt", lexers, filepath.Join(chromaDir, "lexers", "embedded"), filepath.Join("..", "chromalexers", "embedded")); err != nil { + fatal(err) + } + if err := copyAssets("styles.txt", styles, filepath.Join(chromaDir, "styles"), filepath.Join("..", "chromastyles", "embedded")); err != nil { + fatal(err) + } +} + +func chromaModuleDir() (string, error) { + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/alecthomas/chroma/v2") + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("go list chroma module: %w: %s", err, strings.TrimSpace(string(ee.Stderr))) + } + return "", fmt.Errorf("go list chroma module: %w", err) + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + return "", fmt.Errorf("go list returned an empty Chroma module directory") + } + return dir, nil +} + +// copyAssets replaces dstDir with exactly the named assets, so a name dropped +// from the manifest is dropped from the embedded set too. +func copyAssets(manifest string, names []string, srcDir, dstDir string) error { + if err := os.RemoveAll(dstDir); err != nil { + return fmt.Errorf("clear %s: %w", dstDir, err) + } + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dstDir, err) + } + for _, name := range names { + if err := copyFile(filepath.Join(srcDir, name), filepath.Join(dstDir, name)); err != nil { + return err + } + } + fmt.Printf("copied %d assets from %s into %s\n", len(names), manifest, dstDir) + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open %s: %w", src, err) + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return fmt.Errorf("copy %s to %s: %w", src, dst, copyErr) + } + if closeErr != nil { + return fmt.Errorf("close %s: %w", dst, closeErr) + } + return nil +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/internal/chromasubset/lexers.txt b/internal/chromasubset/lexers.txt new file mode 100644 index 0000000..6ec408d --- /dev/null +++ b/internal/chromasubset/lexers.txt @@ -0,0 +1,79 @@ +# Chroma lexer XML files copied into internal/chromalexers/embedded. +# Keep this focused on source/build files that can show up from ELF/Mach-O/PE +# debug info; unsupported languages fall back to syntax.minimalHighlight. +# Go is registered from a local lexer copy because Chroma's Go lexer is not XML. +ada.xml +armasm.xml +arduino.xml +ballerina.xml +bash.xml +beef.xml +c.xml +c#.xml +c++.xml +c3.xml +chapel.xml +cmake.xml +cobol.xml +clojure.xml +crystal.xml +cython.xml +d.xml +dart.xml +diff.xml +docker.xml +dylan.xml +fsharp.xml +fish.xml +fortran.xml +fortranfixed.xml +gas.xml +glsl.xml +groovy.xml +hare.xml +haskell.xml +hlsl.xml +idris.xml +ini.xml +java.xml +javascript.xml +json.xml +julia.xml +kotlin.xml +lean.xml +llvm.xml +lua.xml +makefile.xml +metal.xml +mlir.xml +modelica.xml +modula-2.xml +mojo.xml +nasm.xml +nim.xml +objective-c.xml +objectpascal.xml +ocaml.xml +odin.xml +perl.xml +plaintext.xml +pony.xml +protocol_buffer.xml +python.xml +reasonml.xml +ruby.xml +rust.xml +scala.xml +sql.xml +standard_ml.xml +swift.xml +systemverilog.xml +toml.xml +typescript.xml +vala.xml +v.xml +vb_net.xml +vhdl.xml +xml.xml +yaml.xml +zig.xml diff --git a/internal/chromasubset/styles.txt b/internal/chromasubset/styles.txt new file mode 100644 index 0000000..7069da8 --- /dev/null +++ b/internal/chromasubset/styles.txt @@ -0,0 +1,63 @@ +# Chroma style XML files copied into internal/chromastyles/embedded. +# +# This manifest is the single source of truth for which styles exist: it drives +# both the embedded XML assets (internal/chromastyles) and the palette table +# (internal/theme/palettes_gen.go), so the settings picker can never offer a +# theme whose highlighter is not bundled. +# +# swapoff must stay present because it is the runtime fallback style. +# +# nord, solarized-dark and solarized-light back the hand-tuned built-in presets +# of the same name (see internal/ui/themes.go), which set SyntaxTheme to those +# Chroma styles. settingsThemeList dedupes them out of the Chroma section rather +# than listing them twice. +swapoff.xml +nord.xml +solarized-dark.xml +solarized-light.xml + +abap.xml +arduino.xml +ashen.xml +aura-theme-dark.xml +base16-snazzy.xml +catppuccin-frappe.xml +catppuccin-latte.xml +catppuccin-macchiato.xml +catppuccin-mocha.xml +darcula.xml +doom-one.xml +doom-one2.xml +dracula.xml +evergarden.xml +fruity.xml +github-dark.xml +gruvbox.xml +gruvbox-light.xml +kanagawa-dragon.xml +kanagawa-lotus.xml +kanagawa-wave.xml +modus-operandi.xml +modus-vivendi.xml +monokai.xml +monokailight.xml +native.xml +nordic.xml +onedark.xml +paraiso-dark.xml +paraiso-light.xml +rainbow_dash.xml +rose-pine.xml +rose-pine-dawn.xml +rose-pine-moon.xml +rpgle.xml +rrt.xml +solarized-dark256.xml +tango.xml +tokyonight-day.xml +tokyonight-moon.xml +tokyonight-night.xml +vulcan.xml +witchhazel.xml +xcode.xml +xcode-dark.xml diff --git a/internal/disasm/decoder.go b/internal/disasm/decoder.go index 412dba6..e5b2fd5 100644 --- a/internal/disasm/decoder.go +++ b/internal/disasm/decoder.go @@ -87,6 +87,25 @@ type Inst struct { Class InstClass } +// Mnemonic returns the instruction's first whitespace-delimited token, lowered. +func Mnemonic(text string) string { + text = strings.TrimSpace(text) + if i := strings.IndexAny(text, " \t"); i >= 0 { + text = text[:i] + } + return strings.ToLower(text) +} + +// IsAddrLoad reports whether op materialises an address (so its operand is worth +// annotating with the symbol/section it points at). +func IsAddrLoad(op string) bool { + switch op { + case "lea", "leaq", "leal", "leaw", "adr", "adrp": + return true + } + return false +} + // Classify maps a rendered instruction's mnemonic to an InstClass. Exported so // callers that already hold an Inst.Text (e.g. after Range) can re-classify. func Classify(text string) InstClass { diff --git a/internal/disasm/lookup.go b/internal/disasm/lookup.go new file mode 100644 index 0000000..7b4e4eb --- /dev/null +++ b/internal/disasm/lookup.go @@ -0,0 +1,58 @@ +package disasm + +import "sort" + +// Address lookups over a decoded, address-ordered instruction slice. +// +// Every caller holds such a slice — the disassembly view's window, a function's +// extent, a search result — and asks the same three questions of it. The answers +// are pure functions of the slice, with boundary cases (an address inside an +// instruction's bytes, an address past the last one) that are easy to get subtly +// wrong, so they live here with the type rather than in whoever needs them. +// +// All three assume insts is sorted by Addr, which every decode path produces. + +// IndexForAddr finds the instruction covering addr, or the nearest one at a lower +// address. ok reports whether addr actually falls within the returned +// instruction's bytes (or is exactly its start). +// +// When addr precedes every instruction, it returns (0, false). +func IndexForAddr(insts []Inst, addr uint64) (idx int, ok bool) { + if len(insts) == 0 { + return 0, false + } + i := sort.Search(len(insts), func(i int) bool { return insts[i].Addr > addr }) + if i == 0 { + return 0, false + } + j := i - 1 + in := insts[j] + if addr >= in.Addr && addr < in.Addr+uint64(len(in.Bytes)) { + return j, true + } + return j, in.Addr == addr +} + +// IndexAtOrAfter returns the first instruction at or after addr, falling back to +// the last preceding instruction when there is no later one — so a caller +// scrolling to an address past the decoded window still lands on real code. +func IndexAtOrAfter(insts []Inst, addr uint64) int { + if len(insts) == 0 { + return 0 + } + if idx, ok := IndexForAddr(insts, addr); ok { + return idx + } + if i := sort.Search(len(insts), func(i int) bool { return insts[i].Addr >= addr }); i < len(insts) { + return i + } + return len(insts) - 1 +} + +// HasExact reports whether an instruction starts exactly at addr. It is stricter +// than IndexForAddr's ok, which also accepts an address inside an instruction's +// bytes. +func HasExact(insts []Inst, addr uint64) bool { + i := sort.Search(len(insts), func(i int) bool { return insts[i].Addr >= addr }) + return i < len(insts) && insts[i].Addr == addr +} diff --git a/internal/disasm/lookup_test.go b/internal/disasm/lookup_test.go new file mode 100644 index 0000000..f01b9b3 --- /dev/null +++ b/internal/disasm/lookup_test.go @@ -0,0 +1,104 @@ +package disasm + +import "testing" + +// insts: 0x1000 (4 bytes), 0x1004 (2 bytes), 0x1010 (1 byte). +// The gap between 0x1006 and 0x1010 is deliberate: an address there is covered by +// no instruction, but the nearest preceding one is still the useful answer. +func testInsts() []Inst { + return []Inst{ + {Addr: 0x1000, Bytes: make([]byte, 4)}, + {Addr: 0x1004, Bytes: make([]byte, 2)}, + {Addr: 0x1010, Bytes: make([]byte, 1)}, + } +} + +func TestIndexForAddr(t *testing.T) { + insts := testInsts() + for _, tc := range []struct { + name string + addr uint64 + wantIdx int + wantOK bool + }{ + {"exact first", 0x1000, 0, true}, + {"inside first", 0x1002, 0, true}, + {"last byte of first", 0x1003, 0, true}, + {"exact second", 0x1004, 1, true}, + {"inside second", 0x1005, 1, true}, + {"just past second", 0x1006, 1, false}, // in the gap: nearest below, not covered + {"in the gap", 0x100c, 1, false}, + {"exact third", 0x1010, 2, true}, + {"past the end", 0x2000, 2, false}, + {"before the start", 0x0fff, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + idx, ok := IndexForAddr(insts, tc.addr) + if idx != tc.wantIdx || ok != tc.wantOK { + t.Errorf("IndexForAddr(%#x) = (%d, %v), want (%d, %v)", tc.addr, idx, ok, tc.wantIdx, tc.wantOK) + } + }) + } +} + +func TestIndexForAddrEmpty(t *testing.T) { + if idx, ok := IndexForAddr(nil, 0x1000); idx != 0 || ok { + t.Errorf("IndexForAddr(nil) = (%d, %v), want (0, false)", idx, ok) + } +} + +func TestIndexAtOrAfter(t *testing.T) { + insts := testInsts() + for _, tc := range []struct { + name string + addr uint64 + want int + }{ + {"exact", 0x1004, 1}, + {"inside an instruction", 0x1005, 1}, + {"in the gap rounds up", 0x1008, 2}, + {"before the start rounds up", 0x0fff, 0}, + // Past the last instruction there is nothing later, so the caller lands on + // the last one rather than an out-of-range index. + {"past the end clamps", 0x9999, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IndexAtOrAfter(insts, tc.addr); got != tc.want { + t.Errorf("IndexAtOrAfter(%#x) = %d, want %d", tc.addr, got, tc.want) + } + }) + } +} + +func TestIndexAtOrAfterEmpty(t *testing.T) { + if got := IndexAtOrAfter(nil, 0x1000); got != 0 { + t.Errorf("IndexAtOrAfter(nil) = %d, want 0", got) + } +} + +// TestHasExactIsStricterThanIndexForAddr: an address inside an instruction's +// bytes is "covered" but is not an instruction start. +func TestHasExactIsStricterThanIndexForAddr(t *testing.T) { + insts := testInsts() + for _, tc := range []struct { + addr uint64 + want bool + }{ + {0x1000, true}, + {0x1002, false}, // inside the first instruction + {0x1004, true}, + {0x1006, false}, // the gap + {0x1010, true}, + {0x2000, false}, + } { + if got := HasExact(insts, tc.addr); got != tc.want { + t.Errorf("HasExact(%#x) = %v, want %v", tc.addr, got, tc.want) + } + if _, ok := IndexForAddr(insts, tc.addr); tc.addr == 0x1002 && !ok { + t.Error("IndexForAddr should accept an address inside an instruction") + } + } + if HasExact(nil, 0x1000) { + t.Error("HasExact(nil) reported a hit") + } +} diff --git a/internal/disasm/operand.go b/internal/disasm/operand.go new file mode 100644 index 0000000..9ede10b --- /dev/null +++ b/internal/disasm/operand.go @@ -0,0 +1,55 @@ +package disasm + +import "strconv" + +// FindAddrOperand finds the first 0x-prefixed hex literal in an instruction's +// operand text at or after `from`, returning its value and the byte range it +// occupies. It is the primitive under address highlighting, target annotation, +// branch following and the xref scan, all of which then ask the binary whether +// the value is actually mapped. +// +// A "0x…" immediately preceded by '#' is an ARM immediate ("[sp,#0x8]", "mov +// x0,#0x10"), never an address, and is skipped. Nothing else is filtered here: +// an x86 AT&T immediate is written "$0x401000" and genuinely can be an address +// load, so the caller's mapped-address check is what separates the two. +func FindAddrOperand(text string, from int) (addr uint64, start, end int, ok bool) { + search := from + var idx int + for { + rel := indexFrom(text, search) + if rel < 0 { + return 0, 0, 0, false + } + idx = rel + if idx > 0 && text[idx-1] == '#' { + search = idx + 2 // ARM immediate, not an address — keep looking + continue + } + break + } + rest := text[idx+2:] + n := 0 + for n < len(rest) && isHexDigit(rest[n]) { + n++ + } + if n == 0 { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(rest[:n], 16, 64) + if err != nil { + return 0, 0, 0, false + } + return v, idx, idx + 2 + n, true +} + +// indexFrom returns the absolute index of the next "0x" at or after `from`, or +// -1. A `from` at or past the end finds nothing, which is what the skip-ahead +// above relies on when the text ends in "#0x". +func indexFrom(text string, from int) int { + for i := from; i+1 < len(text); i++ { + if text[i] == '0' && text[i+1] == 'x' { + return i + } + } + return -1 +} diff --git a/internal/disasm/operand_test.go b/internal/disasm/operand_test.go new file mode 100644 index 0000000..3211a47 --- /dev/null +++ b/internal/disasm/operand_test.go @@ -0,0 +1,151 @@ +package disasm + +import ( + "strconv" + "strings" + "testing" +) + +func TestFindAddrOperand(t *testing.T) { + for _, tc := range []struct { + name string + text string + from int + addr uint64 + start int + end int + ok bool + }{ + {name: "no hex at all", text: "ret", ok: false}, + {name: "bare call target", text: "call 0x401020", addr: 0x401020, start: 5, end: 13, ok: true}, + {name: "x86 AT&T immediate is a candidate", text: "mov $0x401000,%rax", addr: 0x401000, start: 5, end: 13, ok: true}, + {name: "uppercase hex digits", text: "jmp 0xDEADBEEF", addr: 0xdeadbeef, start: 4, end: 14, ok: true}, + + // The ARM rule: '#' immediately before "0x" marks an immediate. + {name: "arm immediate skipped", text: "ldr x0,[sp,#0x8]", ok: false}, + {name: "arm immediate skipped, real target after", text: "ldr x0,[sp,#0x8]; b 0x1000", addr: 0x1000, start: 20, end: 26, ok: true}, + {name: "hash not adjacent is not an immediate", text: "b # 0x1000", addr: 0x1000, start: 4, end: 10, ok: true}, + {name: "trailing arm immediate does not run off the end", text: "mov x0,#0x", ok: false}, + {name: "arm immediate at the very end", text: "add sp,sp,#0x10", ok: false}, + + // "0x" with no digits after it is not a number. + {name: "0x with no digits", text: "0x", ok: false}, + {name: "0x followed by a separator", text: "0x,", ok: false}, + {name: "0x then non-hex letter", text: "0xz1", ok: false}, + {name: "0x then 0x", text: "0x0x10", addr: 0x0, start: 0, end: 3, ok: true}, + + // `from` positioning: callers loop by passing back the previous `end`. + {name: "from skips the first", text: "cmp 0x10,0x20", from: 8, addr: 0x20, start: 9, end: 13, ok: true}, + {name: "from past the end", text: "call 0x10", from: 99, ok: false}, + {name: "from exactly at the end", text: "call 0x10", from: 9, ok: false}, + {name: "from at the last byte", text: "call 0x10", from: 8, ok: false}, + + {name: "value overflows 64 bits", text: "0x1ffffffffffffffff", ok: false}, + {name: "max uint64", text: "0xffffffffffffffff", addr: 1<<64 - 1, start: 0, end: 18, ok: true}, + } { + t.Run(tc.name, func(t *testing.T) { + addr, start, end, ok := FindAddrOperand(tc.text, tc.from) + if ok != tc.ok { + t.Fatalf("FindAddrOperand(%q, %d) ok = %v, want %v", tc.text, tc.from, ok, tc.ok) + } + if !ok { + return + } + if addr != tc.addr || start != tc.start || end != tc.end { + t.Errorf("FindAddrOperand(%q, %d) = (%#x, %d, %d), want (%#x, %d, %d)", + tc.text, tc.from, addr, start, end, tc.addr, tc.start, tc.end) + } + // The returned range must actually delimit the literal, since callers + // use it both to colour the span and to resume scanning. + if got := tc.text[start:end]; got != "0x"+strings.TrimPrefix(got, "0x") || !strings.HasPrefix(got, "0x") { + t.Errorf("range %d:%d = %q, which is not a 0x literal", start, end, got) + } + }) + } +} + +// TestFindAddrOperandLoopTerminates: every caller drives this in a `from = end` +// loop, so a call that reports ok must always advance. +func TestFindAddrOperandLoopTerminates(t *testing.T) { + for _, text := range []string{ + "call 0x10", "0x0x0x0", "mov $0x1,%rax; jmp 0x2", "ldr x0,[sp,#0x8]", "0x", "#0x1 0x2", + } { + from, n := 0, 0 + for { + _, _, end, ok := FindAddrOperand(text, from) + if !ok { + break + } + if end <= from { + t.Fatalf("%q: from=%d did not advance (end=%d)", text, from, end) + } + from = end + if n++; n > len(text)+2 { + t.Fatalf("%q: loop did not terminate", text) + } + } + } +} + +// original is the implementation this was extracted from, verbatim. The fuzz +// test below pins the rewrite to it: the inner "0x" scan was rewritten from a +// strings.Index-on-a-suffix to an absolute index walk, and only a differential +// check proves that did not change what the view highlights. +func original(text string, from int) (addr uint64, start, end int, ok bool) { + search := from + var idx int + for { + rel := strings.Index(text[search:], "0x") + if rel < 0 { + return 0, 0, 0, false + } + idx = search + rel + if idx > 0 && text[idx-1] == '#' { + search = idx + 2 + continue + } + break + } + rest := text[idx+2:] + n := 0 + for n < len(rest) { + c := rest[n] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') { + n++ + continue + } + break + } + if n == 0 { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(rest[:n], 16, 64) + if err != nil { + return 0, 0, 0, false + } + return v, idx, idx + 2 + n, true +} + +func FuzzFindAddrOperandMatchesOriginal(f *testing.F) { + for _, s := range []string{ + "call 0x401020", "ldr x0,[sp,#0x8]", "mov $0x1,%rax", "0x", "#0x", "0x0x10", + "b # 0x1000", "add sp,sp,#0x10", "0xffffffffffffffff", "jmp 0xDEADBEEF", "", + } { + for _, from := range []int{0, 1, 2, 5} { + f.Add(s, from) + } + } + f.Fuzz(func(t *testing.T, text string, from int) { + // The original panics on a negative or past-the-end `from`; callers never + // pass one. Match the contract, not the crash. + if from < 0 || from > len(text) { + return + } + wAddr, wStart, wEnd, wOK := original(text, from) + gAddr, gStart, gEnd, gOK := FindAddrOperand(text, from) + if gOK != wOK || gAddr != wAddr || gStart != wStart || gEnd != wEnd { + t.Fatalf("FindAddrOperand(%q, %d) = (%#x,%d,%d,%v), original = (%#x,%d,%d,%v)", + text, from, gAddr, gStart, gEnd, gOK, wAddr, wStart, wEnd, wOK) + } + }) +} diff --git a/internal/dump/relocs.go b/internal/dump/relocs.go index 63e088f..de56b05 100644 --- a/internal/dump/relocs.go +++ b/internal/dump/relocs.go @@ -20,6 +20,23 @@ func Relocs(f *binfile.File) string { var b strings.Builder b.Grow(len(rels) * (addrW + 56)) // size once: offset + type + section + target/row fmt.Fprintf(&b, "%-*s %-24s %-12s %s\n", addrW+2, "Offset", "Type", "Section", "Symbol / Addend") + // Demangle bind-target names (Itanium/Rust) the same way the symbols dump does, + // caching by raw name so a big reloc table doesn't redo the work per row. + demangled := map[string]string{} + symName := func(s string) string { + if s == "" { + return "" + } + d, ok := demangled[s] + if !ok { + d = binfile.DemangleName(s) + demangled[s] = d + } + if d != "" { + return d + } + return s + } // Each row is formatted into one reused buffer (no boxed Fprintf / per-row // Sprintf), so a relocatable object with tens of thousands of relocs stays cheap. var line []byte @@ -32,7 +49,7 @@ func Relocs(f *binfile.File) string { line = append(line, ' ') line = appendLeftStr(line, r.Section, 12) line = append(line, ' ') - line = append(line, r.Sym...) + line = append(line, symName(r.Sym)...) if r.HasAddend { if r.Sym != "" { line = append(line, " + 0x"...) @@ -52,7 +69,7 @@ func Relocs(f *binfile.File) string { func relocsEmptyNote(f *binfile.File) string { switch f.Format { case binfile.FormatMachO: - return "no relocations (linked Mach-O images use dyld bind/rebase or chained fixups, not decoded)\n" + return "no relocations (no dyld bind/rebase, chained fixups, or per-section relocations)\n" case binfile.FormatPE: return "no relocations (no base-relocation directory; stripped or /FIXED image)\n" } diff --git a/internal/dump/syscalls.go b/internal/dump/syscalls.go index 26a3dde..c17f3d1 100644 --- a/internal/dump/syscalls.go +++ b/internal/dump/syscalls.go @@ -356,17 +356,9 @@ func SyscallsFull(f *binfile.File) string { var b strings.Builder fmt.Fprintf(&b, "binary + %d libraries scanned\n", objs-1) writeSyscallsUniqueOrigin(&b, sites) - // On macOS the system libraries (including libsystem_kernel, which holds the - // actual svc instructions) live in the dyld shared cache rather than as files, - // so they can't be opened and scanned — and app/framework code itself almost - // never makes direct syscalls. Collapse the per-library spam into one note. - if f.Format == binfile.FormatMachO && len(notes) > 0 { - fmt.Fprintf(&b, "· %d system libraries are in the dyld shared cache (not standalone files) — their syscalls can't be scanned\n", len(notes)) - } else { - for _, n := range notes { - b.WriteString(n) - b.WriteByte('\n') - } + for _, n := range notes { + b.WriteString(n) + b.WriteByte('\n') } return b.String() } @@ -392,14 +384,72 @@ func CollectSyscallsFullCancel(f *binfile.File, done <-chan struct{}) (sites []S if f.Info == nil || scanCancelled(done) { return sites, objects, notes } - seen := map[string]bool{} + + // The dependency graph is walked breadth-first. On-disk libraries are scanned + // but not followed (their own libc dependency is the same one already in the + // binary's direct list). Cache-resident libraries — macOS system dylibs — are + // extracted from the shared cache and *are* followed, because the library that + // holds the svc instructions (libsystem_kernel) is reached only transitively + // through the libSystem.B re-export umbrella. + cache := newCacheScanner(f.Arch().String()) + defer cache.close() + + type ref struct { + lib string + depth int + } + queue := make([]ref, 0, len(f.Info.DynamicLibs)) for _, lib := range f.Info.DynamicLibs { + queue = append(queue, ref{lib, 1}) + } + seen := map[string]bool{} + cacheObjects := 0 + var cacheNotified bool + + for i := 0; i < len(queue); i++ { if scanCancelled(done) { break } - path, ok := explorer.ResolveLibPath(lib, f.Path, f.Info, nil) + r := queue[i] + if seen[r.lib] { + continue + } + seen[r.lib] = true + + // Cache-resident system libraries (/usr/lib/*, /System/Library/*) take the + // cache path first: macOS leaves stub files at those paths that `stat` + // succeeds on but that carry no code, so resolving them "on disk" would + // scan an empty shell and — worse — never follow the LC_REEXPORT chain that + // reaches libsystem_kernel. Extract them from the shared cache instead and + // follow their dependencies so the transitive syscall surface is covered. + if explorer.IsDyldSharedCacheLib(r.lib) { + if cacheObjects >= maxCacheScanObjects || r.depth > maxCacheScanDepth { + continue + } + lf, ok := cache.file(r.lib) + if !ok { + if !cache.available() && !cacheNotified { + notes = append(notes, "· dyld shared cache not available — system-library syscalls can't be scanned") + cacheNotified = true + } + continue + } + sites = append(sites, scanObjectCancel(lf, baseLibName(r.lib), done)...) + objects++ + cacheObjects++ + for _, dep := range cache.deps(lf) { + if !seen[dep] { + queue = append(queue, ref{dep, r.depth + 1}) + } + } + continue + } + + // Ordinary on-disk dependency (bundled or third-party): scan it, but don't + // follow its deps — its own libc is the same system one handled above. + path, ok := explorer.ResolveLibPath(r.lib, f.Path, f.Info, nil) if !ok { - notes = append(notes, "· "+lib+" — not resolved on disk") + notes = append(notes, "· "+r.lib+" — not resolved on disk") continue } if seen[path] { @@ -408,16 +458,32 @@ func CollectSyscallsFullCancel(f *binfile.File, done <-chan struct{}) (sites []S seen[path] = true lf, err := binfile.Open(path) if err != nil { - notes = append(notes, "· "+lib+" — open failed: "+err.Error()) + notes = append(notes, "· "+r.lib+" — open failed: "+err.Error()) continue } - sites = append(sites, scanObjectCancel(lf, lib, done)...) + sites = append(sites, scanObjectCancel(lf, r.lib, done)...) objects++ lf.Close() } return sites, objects, notes } +// Bounds on the cache-resident dependency walk: deep enough to reach +// libsystem_kernel through libSystem.B (app→umbrella→kernel is depth 2) with +// headroom, and capped so a pathological graph can't extract the whole cache. +const ( + maxCacheScanDepth = 4 + maxCacheScanObjects = 256 +) + +// baseLibName trims a library install path to its basename for the origin tag. +func baseLibName(lib string) string { + if i := strings.LastIndexByte(lib, '/'); i >= 0 { + return lib[i+1:] + } + return lib +} + // SyscallsArchive summarises the system calls provided by a static-library (ar) // archive, scanning every object member and tagging each syscall with the member // it came from. With full=false it prints the merged unique list; with full=true @@ -787,7 +853,7 @@ func chunkHasSyscallCandidate(code []byte, a disasm.Arch, done <-chan struct{}) // syscall (0f 05), sysenter (0f 34), int 0x80/0x2e, gs-indirect call such // as the i386 vsyscall trampoline (65 ff …). for _, p := range [][]byte{{0x0f, 0x05}, {0x0f, 0x34}, {0xcd, 0x80}, {0xcd, 0x2e}, {0x65, 0xff}} { - if bytes.Index(code, p) >= 0 { + if bytes.Contains(code, p) { return true } } diff --git a/internal/dump/syscalls_cache.go b/internal/dump/syscalls_cache.go new file mode 100644 index 0000000..955dff5 --- /dev/null +++ b/internal/dump/syscalls_cache.go @@ -0,0 +1,121 @@ +package dump + +// Scanning cache-resident system libraries for syscall sites. On macOS the +// libraries that actually contain the `svc` instructions (chiefly +// libsystem_kernel) are not standalone files — they live in the dyld shared +// cache — so `syscalls-full` used to report nothing for a Mac binary: the app's +// own code makes no direct syscalls, and its one visible dependency +// (libSystem.B) is a re-export umbrella with no code. cacheScanner extracts +// those libraries straight out of the host cache so their syscalls surface. + +import ( + "os" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/dyldcache" +) + +// cacheScanner lazily opens the host dyld shared cache matching a binary's +// architecture and extracts its images as parseable Mach-O files, memoising by +// install path so a diamond dependency is extracted once. +type cacheScanner struct { + arch string + opened bool + cache *dyldcache.Cache + files map[string]*binfile.File + openErr bool + openHook func(archName string) (*dyldcache.Cache, bool) // overridable in tests +} + +func newCacheScanner(arch string) *cacheScanner { + return &cacheScanner{arch: arch, files: map[string]*binfile.File{}} +} + +// ensure opens the host cache on first need. It returns false (once) when no +// cache is present, so callers fall back to the "can't be scanned" note. +func (cs *cacheScanner) ensure() bool { + if cs.opened { + return cs.cache != nil + } + cs.opened = true + open := cs.openHook + if open == nil { + open = openHostCache + } + c, ok := open(cs.arch) + if !ok { + cs.openErr = true + return false + } + cs.cache = c + return true +} + +// openHostCache locates and opens the running system's dyld shared cache for +// architecture arch. +func openHostCache(arch string) (*dyldcache.Cache, bool) { + path, ok := dyldcache.HostCachePath(arch, func(p string) bool { + _, err := os.Stat(p) + return err == nil + }) + if !ok { + return nil, false + } + c, err := dyldcache.Open(path) + if err != nil { + return nil, false + } + return c, true +} + +// file extracts and parses the cache image named by lib (an install path or a +// unique basename). ok is false when the cache is absent, the image isn't in it, +// or extraction/parse fails — the caller then records a note. +func (cs *cacheScanner) file(lib string) (*binfile.File, bool) { + if !cs.ensure() { + return nil, false + } + im, found := cs.cache.FindImage(lib) + if !found { + return nil, false + } + if f, ok := cs.files[im.Path]; ok { + return f, f != nil + } + buf, err := cs.cache.ExtractImage(im) + if err != nil { + cs.files[im.Path] = nil + return nil, false + } + f, err := binfile.OpenBytes(im.Path, buf) + if err != nil { + cs.files[im.Path] = nil + return nil, false + } + cs.files[im.Path] = f + return f, true +} + +// deps returns the install paths a cache image itself depends on, so the scan +// can follow the app → libSystem.B → libsystem_kernel chain. +func (cs *cacheScanner) deps(f *binfile.File) []string { + if f == nil || f.Info == nil { + return nil + } + return f.Info.DynamicLibs +} + +// available reports whether a host cache could be opened (for the summary note). +func (cs *cacheScanner) available() bool { + return cs.opened && cs.cache != nil +} + +// close releases the mapped cache. The extracted *binfile.File buffers are +// independent copies, so sites collected from them stay valid afterwards. +func (cs *cacheScanner) close() { + if cs.cache != nil { + cs.cache.Close() + cs.cache = nil + } + cs.files = nil +} diff --git a/internal/dyldcache/dyldcache.go b/internal/dyldcache/dyldcache.go new file mode 100644 index 0000000..b405563 --- /dev/null +++ b/internal/dyldcache/dyldcache.go @@ -0,0 +1,342 @@ +// Package dyldcache reads Apple's dyld shared cache: the single large mapped +// image (split across a main file plus ".NN" subcache files) that macOS/iOS ship +// their system dylibs in instead of as standalone files on disk. +// +// The reader is deliberately read-only and header-driven. It parses the cache +// header, the memory mappings, the subcache file list and the image (dylib) +// table, and exposes a virtual-address → bytes translation across every subcache +// file. That is enough to list the dylibs a cache contains and to locate an +// image's Mach-O header for further parsing, without reconstructing ("un-sharing") +// a standalone dylib — the expensive part exex does not need for browsing. +// +// Field offsets follow include/mach-o/dyld_cache_format.h. The header has grown +// over many OS releases, so every field past the original core is read only when +// it lies within mappingOffset (the header's own length): older caches simply do +// not have the newer fields, and reading past mappingOffset would be garbage. +package dyldcache + +import ( + "encoding/binary" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" +) + +// Magic prefixes every dyld shared cache. The full 16-byte magic continues with +// the architecture, e.g. "dyld_v1 arm64e". +const magicPrefix = "dyld_v1" + +// Header field offsets within dyld_cache_header (bytes). Only the fields the +// reader uses are named; see dyld_cache_format.h for the full layout. +const ( + offMagic = 0 // char magic[16] + offMappingOffset = 16 // uint32 — also the header's length + offMappingCount = 20 // uint32 + offImagesOffsetOld = 24 // uint32 (pre-images-relocation caches) + offImagesCountOld = 28 // uint32 + offSubCacheArrayOffset = 392 // uint32 + offSubCacheArrayCount = 396 // uint32 + offImagesOffset = 448 // uint32 (current image-table location) + offImagesCount = 452 // uint32 + offCacheSubType = 456 // uint32 — its presence marks v2 subcache entries + + mappingInfoSize = 32 // dyld_cache_mapping_info + imageInfoSize = 32 // dyld_cache_image_info + subEntryV1Size = 24 // uuid[16] + cacheVMOffset + subEntryV2Size = 56 // v1 + fileSuffix[32] + suffixLen = 32 +) + +// Mapping is one contiguous region of the shared cache: a run of virtual +// addresses backed by a byte range in file File. +type Mapping struct { + Address uint64 // virtual address of the region's first byte + Size uint64 // region length in bytes + FileOffset uint64 // byte offset of the region within File + MaxProt uint32 + InitProt uint32 + File int // index into Cache.files (0 = main cache file) +} + +// contains reports whether addr falls within the mapping. +func (m Mapping) contains(addr uint64) bool { + return addr >= m.Address && addr-m.Address < m.Size +} + +// Image is one dylib stored in the cache, named by its install path and located +// by the virtual address of its Mach-O header. +type Image struct { + Address uint64 + Path string +} + +// subFile is one cache file (the main file or a subcache) held for translation. +type subFile struct { + name string + data []byte +} + +// Cache is a parsed, read-only view of a dyld shared cache and its subcaches. +type Cache struct { + Arch string // architecture from the magic, e.g. "arm64e" + Mappings []Mapping + Images []Image + + files []subFile // index 0 is the main cache file + closers []func() error +} + +// order is the byte order of every dyld cache exex targets: little-endian. +var order = binary.LittleEndian + +// IsCache reports whether raw begins with the dyld shared cache magic. +func IsCache(raw []byte) bool { + return len(raw) >= 16 && strings.HasPrefix(string(raw[:16]), magicPrefix) +} + +// Open maps the shared cache at path and every subcache alongside it, then parses +// the header, mappings and image table. Close releases all mappings. +func Open(path string) (*Cache, error) { + main, closeMain, err := mapFile(path) + if err != nil { + return nil, err + } + c := &Cache{ + files: []subFile{{name: filepath.Base(path), data: main}}, + closers: []func() error{closeMain}, + } + if err := c.parseMain(path); err != nil { + c.Close() + return nil, err + } + return c, nil +} + +// hdrU32 reads a uint32 header field, but only when it lies within the header's +// own declared length (mappingOffset). Fields past that don't exist on the +// (older) cache and read as 0. +func hdrU32(raw []byte, off int) uint32 { + mappingOffset := order.Uint32(raw[offMappingOffset:]) + if off+4 > int(mappingOffset) || off+4 > len(raw) { + return 0 + } + return order.Uint32(raw[off:]) +} + +func (c *Cache) parseMain(path string) error { + raw := c.files[0].data + if !IsCache(raw) { + return errors.New("not a dyld shared cache") + } + if len(raw) < offMappingCount+4 { + return errors.New("dyld cache: truncated header") + } + c.Arch = parseArch(raw[:16]) + + mappingOffset := order.Uint32(raw[offMappingOffset:]) + mappingCount := order.Uint32(raw[offMappingCount:]) + if int(mappingOffset) > len(raw) { + return fmt.Errorf("dyld cache: mappingOffset %d past end", mappingOffset) + } + + // Open subcaches first so their mappings join the address-translation table. + if err := c.openSubCaches(path, raw); err != nil { + return err + } + + if err := c.readMappings(0, raw, mappingOffset, mappingCount); err != nil { + return err + } + // Re-read every subcache's mappings into the shared table now that files exist. + for i := 1; i < len(c.files); i++ { + sub := c.files[i].data + if len(sub) < offMappingCount+4 || !IsCache(sub) { + continue + } + mo := order.Uint32(sub[offMappingOffset:]) + mc := order.Uint32(sub[offMappingCount:]) + if err := c.readMappings(i, sub, mo, mc); err != nil { + return err + } + } + sort.Slice(c.Mappings, func(i, j int) bool { return c.Mappings[i].Address < c.Mappings[j].Address }) + + return c.readImages(raw) +} + +// readMappings appends the mappingCount mappings of file fileIdx to the table. +func (c *Cache) readMappings(fileIdx int, raw []byte, mappingOffset, mappingCount uint32) error { + end := int(mappingOffset) + int(mappingCount)*mappingInfoSize + if end > len(raw) { + return fmt.Errorf("dyld cache: mapping table past end (file %d)", fileIdx) + } + for i := uint32(0); i < mappingCount; i++ { + off := int(mappingOffset) + int(i)*mappingInfoSize + c.Mappings = append(c.Mappings, Mapping{ + Address: order.Uint64(raw[off:]), + Size: order.Uint64(raw[off+8:]), + FileOffset: order.Uint64(raw[off+16:]), + MaxProt: order.Uint32(raw[off+24:]), + InitProt: order.Uint32(raw[off+28:]), + File: fileIdx, + }) + } + return nil +} + +// readImages reads the dylib image table (path + header address). Newer caches +// moved it from imagesOffsetOld to imagesOffset; use whichever the header sets. +func (c *Cache) readImages(raw []byte) error { + imagesOffset := hdrU32(raw, offImagesOffset) + imagesCount := hdrU32(raw, offImagesCount) + if imagesOffset == 0 { + imagesOffset = order.Uint32(raw[offImagesOffsetOld:]) + imagesCount = order.Uint32(raw[offImagesCountOld:]) + } + if imagesOffset == 0 || imagesCount == 0 { + return nil // a cache with no image table is unusual but not fatal + } + end := int(imagesOffset) + int(imagesCount)*imageInfoSize + if end > len(raw) { + return fmt.Errorf("dyld cache: image table past end") + } + c.Images = make([]Image, 0, imagesCount) + for i := uint32(0); i < imagesCount; i++ { + off := int(imagesOffset) + int(i)*imageInfoSize + addr := order.Uint64(raw[off:]) + pathOff := order.Uint32(raw[off+24:]) + c.Images = append(c.Images, Image{Address: addr, Path: cStringAt(raw, int(pathOff))}) + } + return nil +} + +// openSubCaches maps the subcache files named by the header's subcache array. It +// is best-effort: a missing subcache degrades address translation for that file's +// ranges but does not fail the whole cache (browsing the image list still works). +func (c *Cache) openSubCaches(path string, raw []byte) error { + arrOff := hdrU32(raw, offSubCacheArrayOffset) + arrCount := hdrU32(raw, offSubCacheArrayCount) + if arrOff == 0 || arrCount == 0 { + return nil + } + stride := subEntryV1Size + hasSuffix := int(order.Uint32(raw[offMappingOffset:])) > offCacheSubType + if hasSuffix { + stride = subEntryV2Size + } + end := int(arrOff) + int(arrCount)*stride + if end > len(raw) { + return fmt.Errorf("dyld cache: subcache array past end") + } + for i := uint32(0); i < arrCount; i++ { + off := int(arrOff) + int(i)*stride + suffix := fmt.Sprintf(".%02d", i+1) // v1 caches imply sequential suffixes + if hasSuffix { + suffix = cString(raw[off+subEntryV1Size : off+subEntryV1Size+suffixLen]) + } + subPath := path + suffix + data, closer, err := mapFile(subPath) + if err != nil { + // Record an empty file so File indices stay aligned with the array. + c.files = append(c.files, subFile{name: filepath.Base(subPath)}) + continue + } + c.files = append(c.files, subFile{name: filepath.Base(subPath), data: data}) + c.closers = append(c.closers, closer) + } + return nil +} + +// BytesAt returns up to n bytes starting at virtual address addr, resolving which +// subcache file backs it. ok is false when no mapping covers addr. +func (c *Cache) BytesAt(addr uint64, n int) (data []byte, ok bool) { + m := c.mappingFor(addr) + if m == nil { + return nil, false + } + avail := m.Size - (addr - m.Address) + if uint64(n) > avail { + n = int(avail) + } + fileOff := m.FileOffset + (addr - m.Address) + buf := c.files[m.File].data + if buf == nil || fileOff+uint64(n) > uint64(len(buf)) { + return nil, false + } + return buf[fileOff : fileOff+uint64(n)], true +} + +// mappingFor returns the mapping covering addr via binary search, or nil. +func (c *Cache) mappingFor(addr uint64) *Mapping { + i := sort.Search(len(c.Mappings), func(i int) bool { return c.Mappings[i].Address > addr }) + if i == 0 { + return nil + } + if m := &c.Mappings[i-1]; m.contains(addr) { + return m + } + return nil +} + +// SubCacheNames returns the file names of the main cache and every subcache, in +// File-index order. +func (c *Cache) SubCacheNames() []string { + out := make([]string, len(c.files)) + for i, f := range c.files { + out[i] = f.name + } + return out +} + +// Close releases every mapped cache file. +func (c *Cache) Close() error { + var err error + for _, closer := range c.closers { + if closer != nil { + if e := closer(); e != nil && err == nil { + err = e + } + } + } + c.closers = nil + c.files = nil + c.Mappings = nil + return err +} + +// parseArch extracts the architecture token from the 16-byte magic +// ("dyld_v1 arm64e" → "arm64e"). +func parseArch(magic []byte) string { + s := cString(magic) + return strings.TrimSpace(strings.TrimPrefix(s, "dyld_v1")) +} + +// cString reads a NUL-terminated string from a fixed byte slice. +func cString(b []byte) string { + if i := indexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +// cStringAt reads a NUL-terminated string from raw starting at off (bounded). +func cStringAt(raw []byte, off int) string { + if off < 0 || off >= len(raw) { + return "" + } + if i := indexByte(raw[off:], 0); i >= 0 { + return string(raw[off : off+i]) + } + return string(raw[off:]) +} + +func indexByte(b []byte, c byte) int { + for i := 0; i < len(b); i++ { + if b[i] == c { + return i + } + } + return -1 +} diff --git a/internal/dyldcache/dyldcache_test.go b/internal/dyldcache/dyldcache_test.go new file mode 100644 index 0000000..9c2755b --- /dev/null +++ b/internal/dyldcache/dyldcache_test.go @@ -0,0 +1,157 @@ +package dyldcache + +import ( + "os" + "path/filepath" + "testing" +) + +// buildSyntheticCache writes a minimal but structurally valid v2 dyld cache with +// one subcache, one mapping in each, and two images, returning the main path. +func buildSyntheticCache(t *testing.T, dir string) string { + t.Helper() + + const ( + mappingOffset = 552 + imagesOffset = 664 + subArrOffset = 900 + strOffset = 1200 // where image path strings live in the main file + ) + // Region A (main file) covers VA 0x1000.., Region B (subcache) VA 0x8000.. + main := make([]byte, 4096) + copy(main, "dyld_v1 arm64e\x00") + order.PutUint32(main[offMappingOffset:], mappingOffset) + order.PutUint32(main[offMappingCount:], 1) + order.PutUint32(main[offImagesOffset:], imagesOffset) + order.PutUint32(main[offImagesCount:], 2) + order.PutUint32(main[offSubCacheArrayOffset:], subArrOffset) + order.PutUint32(main[offSubCacheArrayCount:], 1) + + // main mapping: VA 0x1000, size 0x1000, fileOffset 0 + order.PutUint64(main[mappingOffset:], 0x1000) + order.PutUint64(main[mappingOffset+8:], 0x1000) + order.PutUint64(main[mappingOffset+16:], 0) + + // images: one in the main mapping, one in the subcache mapping + pathA := "/usr/lib/libA.dylib" + pathB := "/usr/lib/libB.dylib" + copy(main[strOffset:], pathA+"\x00") + copy(main[strOffset+64:], pathB+"\x00") + order.PutUint64(main[imagesOffset:], 0x1100) // libA header VA (in main mapping) + order.PutUint32(main[imagesOffset+24:], strOffset) + order.PutUint64(main[imagesOffset+imageInfoSize:], 0x8100) // libB header VA (subcache) + order.PutUint32(main[imagesOffset+imageInfoSize+24:], strOffset+64) + + // v2 subcache entry: uuid, cacheVMOffset, fileSuffix ".01" + order.PutUint64(main[subArrOffset+16:], 0x7000) + copy(main[subArrOffset+subEntryV1Size:], ".01\x00") + + // Subcache file: header + one mapping covering VA 0x8000. + sub := make([]byte, 4096) + copy(sub, "dyld_v1 arm64e\x00") + order.PutUint32(sub[offMappingOffset:], mappingOffset) + order.PutUint32(sub[offMappingCount:], 1) + order.PutUint64(sub[mappingOffset:], 0x8000) // VA + order.PutUint64(sub[mappingOffset+8:], 0x1000) // size + order.PutUint64(sub[mappingOffset+16:], 0) // fileOffset + sub[0x100] = 0xAB // marker byte at VA 0x8100 + + main[0x100] = 0xCD // marker byte at VA 0x1100 + + mainPath := filepath.Join(dir, "dyld_shared_cache_arm64e") + if err := os.WriteFile(mainPath, main, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mainPath+".01", sub, 0o644); err != nil { + t.Fatal(err) + } + return mainPath +} + +func TestOpenSyntheticCache(t *testing.T) { + dir := t.TempDir() + path := buildSyntheticCache(t, dir) + + c, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + if c.Arch != "arm64e" { + t.Errorf("Arch = %q, want arm64e", c.Arch) + } + if len(c.Images) != 2 { + t.Fatalf("images = %d, want 2", len(c.Images)) + } + if c.Images[0].Path != "/usr/lib/libA.dylib" || c.Images[1].Path != "/usr/lib/libB.dylib" { + t.Errorf("image paths = %q, %q", c.Images[0].Path, c.Images[1].Path) + } + if len(c.Mappings) != 2 { + t.Fatalf("mappings = %d, want 2 (main + subcache)", len(c.Mappings)) + } + if names := c.SubCacheNames(); len(names) != 2 || names[1] != "dyld_shared_cache_arm64e.01" { + t.Errorf("subcache names = %v", names) + } +} + +func TestBytesAtCrossesSubcaches(t *testing.T) { + dir := t.TempDir() + c, err := Open(buildSyntheticCache(t, dir)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + // VA 0x1100 lives in the main file (marker 0xCD). + if b, ok := c.BytesAt(0x1100, 1); !ok || b[0] != 0xCD { + t.Errorf("BytesAt(0x1100) = %v ok=%v, want [0xCD]", b, ok) + } + // VA 0x8100 lives in the subcache file (marker 0xAB) — translation must find it. + if b, ok := c.BytesAt(0x8100, 1); !ok || b[0] != 0xAB { + t.Errorf("BytesAt(0x8100) = %v ok=%v, want [0xAB]", b, ok) + } + // An address in no mapping fails cleanly. + if _, ok := c.BytesAt(0x50000, 4); ok { + t.Errorf("BytesAt(unmapped) ok=true, want false") + } + // A short-read at the end of a mapping is clamped, not out of bounds. + if b, ok := c.BytesAt(0x1FFF, 16); !ok || len(b) != 1 { + t.Errorf("BytesAt near end = len %d ok=%v, want 1", len(b), ok) + } +} + +func TestIsCache(t *testing.T) { + if !IsCache([]byte("dyld_v1 arm64e\x00")) { + t.Error("valid magic rejected") + } + if IsCache([]byte("\x7fELF")) { + t.Error("ELF accepted as cache") + } + if IsCache([]byte("short")) { + t.Error("short buffer accepted") + } +} + +func TestOldImagesFallback(t *testing.T) { + // A cache that only sets imagesOffsetOld must still resolve its images. + dir := t.TempDir() + path := buildSyntheticCache(t, dir) + raw, _ := os.ReadFile(path) + // Move the two-image table's location into the *Old fields and zero the new. + order.PutUint32(raw[offImagesOffset:], 0) + order.PutUint32(raw[offImagesCount:], 0) + order.PutUint32(raw[offImagesOffsetOld:], 664) + order.PutUint32(raw[offImagesCountOld:], 2) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + c, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if len(c.Images) != 2 { + t.Fatalf("images via Old fields = %d, want 2", len(c.Images)) + } +} diff --git a/internal/dyldcache/extract.go b/internal/dyldcache/extract.go new file mode 100644 index 0000000..a03bda5 --- /dev/null +++ b/internal/dyldcache/extract.go @@ -0,0 +1,461 @@ +package dyldcache + +// Extracting ("un-sharing") one dylib out of the cache. A cache-resident dylib +// cannot be parsed as a standalone Mach-O directly: its segments are scattered +// across the cache's address space (and across subcache files), and every file +// offset in its load commands refers to the cache layout, not to any single +// file. ExtractImage stitches the segments back into one contiguous buffer and +// rewrites the offsets so ordinary Mach-O parsers (binfile.OpenBytes) accept +// it: each segment is copied via the address-translation table and laid out +// page-aligned in load-command order, segment/section file offsets are rebased +// to the new layout, and every __LINKEDIT-relative table offset (symbols, +// strings, indirect symbols, exports, function starts, …) is shifted by +// __LINKEDIT's move. +// +// The result is a *browsable* dylib — sections, symbols, strings, disassembly +// and syscall scanning all work. It is not a runnable one: pointers in data +// segments keep their in-cache values (the cache builder discarded the rebase +// info that dyld would need to slide them), and stubs stay cache-optimised. +// +// __LINKEDIT is a special case: in the cache it is one giant region shared by +// every image — the symbol table, and especially the string pool, are unified +// across all dylibs, so this image's LC_SYMTAB points into a hundreds-of-MB +// shared blob even though the dylib itself has only a few hundred symbols. +// Copying it verbatim (or even trimming by offset range) would make every +// extracted dylib enormous. Instead the extractor rebuilds a compact, +// self-contained __LINKEDIT holding just this image's nlist symbols, a fresh +// string table containing only the strings those symbols name, its indirect +// symbol table and function-starts; the shared-only tables (bind/rebase/export +// info, split-seg info, code signature) are dropped, since browsing doesn't +// need them. + +import ( + "errors" + "fmt" + "strings" +) + +// Mach-O constants used by the rewrite (from mach-o/loader.h). +const ( + magic64 = 0xfeedfacf + + lcSegment64 = 0x19 + lcSymtab = 0x2 + lcDysymtab = 0xb + lcDyldInfo = 0x22 + lcReqDyld = 0x80000000 // LC_REQ_DYLD bit: lcDyldInfo|lcReqDyld = LC_DYLD_INFO_ONLY + lcCodeSig = 0x1d + lcSplitInfo = 0x1e + lcFuncStarts = 0x26 + lcDataInCode = 0x29 + lcCodeSignDR = 0x2b + lcLinkerOpt = 0x2e + lcExportsTrie = 0x80000033 + lcChainedFixups = 0x80000034 + + mhDylibInCache = 0x80000000 // header flag set on cache-resident dylibs + + headerSize64 = 32 + segCmdSize64 = 72 + sectionSize64 = 80 +) + +// extractMaxBytes caps an extracted dylib's stitched size, guarding against a +// corrupt image table pointing at garbage segment sizes. +const extractMaxBytes = 2 << 30 + +// FindImage returns the cache image whose install path is installPath, falling +// back to a unique basename match (so "libSystem.B.dylib" finds +// "/usr/lib/libSystem.B.dylib"). +func (c *Cache) FindImage(installPath string) (Image, bool) { + for _, im := range c.Images { + if im.Path == installPath { + return im, true + } + } + base := "/" + baseName(installPath) + var hit Image + n := 0 + for _, im := range c.Images { + if strings.HasSuffix(im.Path, base) { + hit = im + n++ + } + } + return hit, n == 1 +} + +func baseName(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// extractSeg is one LC_SEGMENT_64 of the image being extracted. +type extractSeg struct { + cmdOff int // offset of the load command within the load-command area + name string + vmaddr uint64 + filesz uint64 + fileoff uint64 // in-cache file offset (the rebase base for its sections) + newoff uint64 // file offset in the stitched output +} + +// ExtractImage stitches the cache-resident dylib im into a standalone Mach-O +// image parseable by binfile.OpenBytes. +func (c *Cache) ExtractImage(im Image) ([]byte, error) { + hdr, ok := c.BytesAt(im.Address, headerSize64) + if !ok || len(hdr) < headerSize64 { + return nil, errors.New("dyld cache: image header not mapped") + } + if order.Uint32(hdr) != magic64 { + return nil, fmt.Errorf("dyld cache: image at %#x is not a 64-bit Mach-O", im.Address) + } + sizeofcmds := int(order.Uint32(hdr[20:])) + cmds, ok := c.BytesAt(im.Address+headerSize64, sizeofcmds) + if !ok || len(cmds) < sizeofcmds { + return nil, errors.New("dyld cache: load commands not mapped") + } + ncmds := int(order.Uint32(hdr[16:])) + + // Pass 1: collect the segments and assign their stitched offsets. + var segs []extractSeg + var linkedit *extractSeg + total := uint64(0) + for off, i := 0, 0; i < ncmds && off+8 <= len(cmds); i++ { + cmd := order.Uint32(cmds[off:]) + cmdsize := int(order.Uint32(cmds[off+4:])) + if cmdsize < 8 || off+cmdsize > len(cmds) { + return nil, errors.New("dyld cache: corrupt load command") + } + if cmd == lcSegment64 && cmdsize >= segCmdSize64 { + segs = append(segs, extractSeg{ + cmdOff: off, + name: cString(cmds[off+8 : off+24]), + vmaddr: order.Uint64(cmds[off+24:]), + filesz: order.Uint64(cmds[off+48:]), + fileoff: order.Uint64(cmds[off+40:]), + }) + } + off += cmdsize + } + if len(segs) == 0 || segs[0].vmaddr != im.Address { + return nil, errors.New("dyld cache: image's first segment does not start at its header") + } + + // Build the compact self-contained __LINKEDIT for this image, and drop the + // original shared one from the layout (its filesz spans the whole cache). + le, err := c.buildLinkedit(cmds, ncmds) + if err != nil { + return nil, err + } + for i := range segs { + if segs[i].name == "__LINKEDIT" { + linkedit = &segs[i] + segs[i].filesz = uint64(len(le.data)) + } + } + + for i := range segs { + segs[i].newoff = total + total += (segs[i].filesz + 0xfff) &^ 0xfff // page-align each segment + if total > extractMaxBytes { + return nil, fmt.Errorf("dyld cache: image %s unreasonably large (%d MB)", im.Path, total>>20) + } + } + + // Pass 2: copy the segment contents via address translation; __LINKEDIT is + // the freshly built compact buffer. + out := make([]byte, total) + for _, s := range segs { + if s.filesz == 0 { + continue + } + if s.name == "__LINKEDIT" { + copy(out[s.newoff:], le.data) + continue + } + src, ok := c.BytesAt(s.vmaddr, int(s.filesz)) + if !ok || uint64(len(src)) < s.filesz { + return nil, fmt.Errorf("dyld cache: segment %s of %s not fully mapped", s.name, im.Path) + } + copy(out[s.newoff:], src[:s.filesz]) + } + + // Pass 3: rewrite the copied load commands for the stitched layout. The + // header and load commands sit at the start of the first segment (offset 0). + oc := out[headerSize64 : headerSize64+sizeofcmds] + for _, s := range segs { + order.PutUint64(oc[s.cmdOff+40:], s.newoff) // fileoff + if s.name == "__LINKEDIT" { + // The command still carries the cache's huge vm/file sizes; shrink them + // to the compact copy so the segment's file range stays inside the buffer. + order.PutUint64(oc[s.cmdOff+32:], (s.filesz+0xfff)&^0xfff) // vmsize + order.PutUint64(oc[s.cmdOff+48:], s.filesz) // filesz + } + delta := int64(s.newoff) - int64(s.fileoff) + nsects := int(order.Uint32(oc[s.cmdOff+64:])) + for j := range nsects { + so := s.cmdOff + segCmdSize64 + j*sectionSize64 + if so+sectionSize64 > len(oc) { + return nil, errors.New("dyld cache: section table past load commands") + } + shiftU32(oc[so+48:], delta) // section offset (0 for zero-fill stays 0) + } + } + if linkedit != nil { + le.rewriteCommands(oc, ncmds, linkedit.newoff) + } + // Clear the in-cache flag so downstream tooling treats it as standalone. + flags := order.Uint32(out[24:]) + order.PutUint32(out[24:], flags&^mhDylibInCache) + return out, nil +} + +// compactLinkedit is a freshly built, self-contained __LINKEDIT for one image: +// its symbol table (with a private string pool), indirect symbol table and +// function-starts, laid out contiguously. Offsets are relative to the segment +// start; rewriteCommands turns them into file offsets once its final position +// is known. +type compactLinkedit struct { + data []byte + + nsyms uint32 + symRel int + strRel, strLen int + indRel int + nindirect uint32 + funcRel int + funcLen int +} + +// buildLinkedit reads this image's symbol/indirect/function-starts tables out of +// the shared cache linkedit and packs them, with a private string table, into a +// compact standalone linkedit. The shared-only tables (bind/rebase/export info, +// split-seg, code signature) are omitted — browsing needs none of them. +func (c *Cache) buildLinkedit(cmds []byte, ncmds int) (*compactLinkedit, error) { + u32 := func(b []byte, o int) uint32 { + if o+4 > len(b) { + return 0 + } + return order.Uint32(b[o:]) + } + // leByteAt reads n bytes at a linkedit *file offset* by translating it to a + // virtual address through the cache mapping table. + var leVmaddr, leFileoff, leFilesz uint64 + haveLE := false + for off, i := 0, 0; i < ncmds && off+8 <= len(cmds); i++ { + cmd := order.Uint32(cmds[off:]) + cmdsize := int(order.Uint32(cmds[off+4:])) + if cmd == lcSegment64 && cString(cmds[off+8:off+24]) == "__LINKEDIT" { + leVmaddr = order.Uint64(cmds[off+24:]) + leFileoff = order.Uint64(cmds[off+40:]) + leFilesz = order.Uint64(cmds[off+48:]) + haveLE = true + } + off += cmdsize + } + if !haveLE { + return &compactLinkedit{data: []byte{0}}, nil // no linkedit at all + } + leByteAt := func(fileOff uint64, n int) ([]byte, bool) { + if fileOff < leFileoff || fileOff+uint64(n) > leFileoff+leFilesz { + return nil, false + } + return c.BytesAt(leVmaddr+(fileOff-leFileoff), n) + } + + // Locate LC_SYMTAB, LC_DYSYMTAB (indirect syms), LC_FUNCTION_STARTS. + var symOff, nsyms, strOff, strSize uint32 + var indOff, nindirect uint32 + var funcOff, funcSize uint32 + for off, i := 0, 0; i < ncmds && off+8 <= len(cmds); i++ { + cmd := order.Uint32(cmds[off:]) + cmdsize := int(order.Uint32(cmds[off+4:])) + switch cmd { + case lcSymtab: + symOff, nsyms = u32(cmds, off+8), u32(cmds, off+12) + strOff, strSize = u32(cmds, off+16), u32(cmds, off+20) + case lcDysymtab: + indOff, nindirect = u32(cmds, off+56), u32(cmds, off+60) + case lcFuncStarts: + funcOff, funcSize = u32(cmds, off+8), u32(cmds, off+12) + } + off += cmdsize + } + + lc := &compactLinkedit{nsyms: nsyms, nindirect: nindirect} + var buf []byte + buf = append(buf, 0) // reserve index 0 as the empty string + strIndex := map[string]uint32{"": 0} + intern := func(s string) uint32 { + if idx, ok := strIndex[s]; ok { + return idx + } + idx := uint32(len(buf)) + strIndex[s] = idx + buf = append(buf, s...) + buf = append(buf, 0) + return idx + } + + // Symbol table: copy each nlist_64, re-pointing n_strx into the private pool. + sym := make([]byte, int(nsyms)*16) + if nsyms > 0 { + src, ok := leByteAt(uint64(symOff), int(nsyms)*16) + if !ok { + return nil, errors.New("dyld cache: symbol table not mapped") + } + copy(sym, src) + for k := range int(nsyms) { + nStrx := order.Uint32(sym[k*16:]) + name := c.cStringInLinkedit(leVmaddr, leFileoff, leFilesz, uint64(strOff)+uint64(nStrx)) + order.PutUint32(sym[k*16:], intern(name)) + } + } + _ = strSize + + // Assemble: [strtab | symtab | indirect | funcstarts], 8-byte aligned tables. + pad := func() { + for len(buf)%8 != 0 { + buf = append(buf, 0) + } + } + lc.strRel, lc.strLen = 0, len(buf) + pad() + lc.symRel = len(buf) + buf = append(buf, sym...) + if nindirect > 0 { + if src, ok := leByteAt(uint64(indOff), int(nindirect)*4); ok { + lc.indRel = len(buf) + buf = append(buf, src...) + } else { + lc.nindirect = 0 + } + } + if funcSize > 0 { + if src, ok := leByteAt(uint64(funcOff), int(funcSize)); ok { + pad() + lc.funcRel, lc.funcLen = len(buf), int(funcSize) + buf = append(buf, src...) + } + } + pad() + lc.data = buf + return lc, nil +} + +// rewriteCommands points this image's LC_SYMTAB / LC_DYSYMTAB (indirect) / +// LC_FUNCTION_STARTS at the compact linkedit now placed at file offset base, +// and zeroes the load commands whose shared-only tables were dropped so no +// parser chases them into the (absent) cache linkedit. +func (lc *compactLinkedit) rewriteCommands(oc []byte, ncmds int, base uint64) { + for off, i := 0, 0; i < ncmds && off+8 <= len(oc); i++ { + cmd := order.Uint32(oc[off:]) + cmdsize := int(order.Uint32(oc[off+4:])) + if cmdsize < 8 || off+cmdsize > len(oc) { + return + } + switch cmd { + case lcSymtab: + order.PutUint32(oc[off+8:], uint32(base)+uint32(lc.symRel)) + order.PutUint32(oc[off+12:], lc.nsyms) + order.PutUint32(oc[off+16:], uint32(base)+uint32(lc.strRel)) + order.PutUint32(oc[off+20:], uint32(lc.strLen)) + case lcDysymtab: + // Keep only the indirect symbol table; drop the toc/module/reference + // and relocation tables (shared, unused for browsing). + for _, fo := range []int{32, 36, 40, 44, 48, 52, 64, 68, 72, 76} { + order.PutUint32(oc[off+fo:], 0) + } + if lc.nindirect > 0 { + order.PutUint32(oc[off+56:], uint32(base)+uint32(lc.indRel)) + order.PutUint32(oc[off+60:], lc.nindirect) + } else { + order.PutUint32(oc[off+56:], 0) + order.PutUint32(oc[off+60:], 0) + } + case lcFuncStarts: + if lc.funcLen > 0 { + order.PutUint32(oc[off+8:], uint32(base)+uint32(lc.funcRel)) + order.PutUint32(oc[off+12:], uint32(lc.funcLen)) + } else { + order.PutUint32(oc[off+8:], 0) + order.PutUint32(oc[off+12:], 0) + } + case lcDyldInfo, lcDyldInfo | lcReqDyld: + for _, fo := range []int{8, 12, 16, 20, 24, 28, 32, 36, 40, 44} { + order.PutUint32(oc[off+fo:], 0) + } + case lcCodeSig, lcSplitInfo, lcDataInCode, lcCodeSignDR, lcLinkerOpt, + lcExportsTrie, lcChainedFixups: + order.PutUint32(oc[off+8:], 0) + order.PutUint32(oc[off+12:], 0) + } + off += cmdsize + } +} + +// cStringInLinkedit reads a NUL-terminated string at a linkedit file offset, +// translating through the cache mapping in bounded chunks. +func (c *Cache) cStringInLinkedit(leVmaddr, leFileoff, leFilesz, fileOff uint64) string { + if fileOff < leFileoff || fileOff >= leFileoff+leFilesz { + return "" + } + addr := leVmaddr + (fileOff - leFileoff) + b, ok := c.BytesAt(addr, 4096) + if !ok { + if b, ok = c.BytesAt(addr, 256); !ok { + return "" + } + } + if i := indexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +// shiftU32 adds delta to the uint32 at b when it is nonzero (zero means "no +// such table" in every command it is used for, and must stay zero). +func shiftU32(b []byte, delta int64) { + v := order.Uint32(b) + if v == 0 { + return + } + order.PutUint32(b, uint32(int64(v)+delta)) +} + +// hostCacheDirs lists where the running system's dyld shared cache lives, +// newest layout first. +var hostCacheDirs = []string{ + "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld", + "/System/Library/dyld", +} + +// cacheArchNames maps a binary's architecture to the cache-file arch suffixes +// that can serve it, in preference order (arm64 binaries are served by the +// arm64e cache on Apple Silicon; x86_64h serves x86_64 on Haswell+ Macs). +func cacheArchNames(a string) []string { + switch a { + case "arm64", "arm64e": + return []string{"arm64e", "arm64"} + case "amd64", "x86_64", "x86_64h": + return []string{"x86_64h", "x86_64"} + } + return []string{"arm64e", "arm64", "x86_64h", "x86_64"} +} + +// HostCachePath locates the running system's dyld shared cache serving +// architecture a (a binfile/arch name like "arm64" or "amd64"; "" tries all). +func HostCachePath(a string, exists func(string) bool) (string, bool) { + for _, dir := range hostCacheDirs { + for _, an := range cacheArchNames(a) { + p := dir + "/dyld_shared_cache_" + an + if exists(p) { + return p, true + } + } + } + return "", false +} diff --git a/internal/dyldcache/extract_test.go b/internal/dyldcache/extract_test.go new file mode 100644 index 0000000..548d9ae --- /dev/null +++ b/internal/dyldcache/extract_test.go @@ -0,0 +1,98 @@ +package dyldcache + +import ( + "os" + "testing" +) + +// openRealCache opens the host's dyld shared cache, skipping when absent. +func openRealCache(t *testing.T) *Cache { + t.Helper() + var path string + for _, p := range realCachePaths { + if _, err := os.Stat(p); err == nil { + path = p + break + } + } + if path == "" { + t.Skip("no system dyld shared cache on this host") + } + c, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close() }) + return c +} + +// TestExtractRealDylib un-shares a real system dylib and checks the stitched +// image is a self-consistent standalone Mach-O: header magic, every segment's +// rewritten file range inside the buffer, and the symbol/string tables inside +// the relocated __LINKEDIT. +func TestExtractRealDylib(t *testing.T) { + c := openRealCache(t) + + im, ok := c.FindImage("/usr/lib/libSystem.B.dylib") + if !ok { + t.Fatal("libSystem.B.dylib not in cache image table") + } + out, err := c.ExtractImage(im) + if err != nil { + t.Fatal(err) + } + if order.Uint32(out) != magic64 { + t.Fatalf("stitched image magic = %#x", order.Uint32(out)) + } + if order.Uint32(out[24:])&mhDylibInCache != 0 { + t.Fatal("MH_DYLIB_IN_CACHE flag not cleared") + } + + // Walk the rewritten load commands: segments and linkedit tables must lie + // within the stitched buffer. + ncmds := int(order.Uint32(out[16:])) + sizeofcmds := int(order.Uint32(out[20:])) + oc := out[headerSize64 : headerSize64+sizeofcmds] + var symoff, stroff, strsize uint32 + segs := 0 + for off, i := 0, 0; i < ncmds; i++ { + cmd := order.Uint32(oc[off:]) + cmdsize := int(order.Uint32(oc[off+4:])) + switch cmd { + case lcSegment64: + segs++ + fileoff := order.Uint64(oc[off+40:]) + filesz := order.Uint64(oc[off+48:]) + if fileoff+filesz > uint64(len(out)) { + t.Fatalf("segment %s [%#x,+%#x) outside stitched buffer (%#x)", + cString(oc[off+8:off+24]), fileoff, filesz, len(out)) + } + case lcSymtab: + symoff = order.Uint32(oc[off+8:]) + stroff = order.Uint32(oc[off+16:]) + strsize = order.Uint32(oc[off+20:]) + } + off += cmdsize + } + if segs < 2 { + t.Fatalf("only %d segments", segs) + } + if symoff == 0 || int(symoff) > len(out) { + t.Fatalf("rewritten symoff %#x outside buffer (%#x)", symoff, len(out)) + } + if int(stroff)+int(strsize) > len(out) { + t.Fatalf("rewritten string table [%#x,+%#x) outside buffer (%#x)", stroff, strsize, len(out)) + } +} + +// TestFindImageByBasename checks the unique-basename fallback. +func TestFindImageByBasename(t *testing.T) { + c := openRealCache(t) + im, ok := c.FindImage("libSystem.B.dylib") + if !ok { + t.Fatal("basename lookup failed") + } + if im.Path != "/usr/lib/libSystem.B.dylib" { + t.Fatalf("basename lookup found %q", im.Path) + } +} diff --git a/internal/dyldcache/mmap_other.go b/internal/dyldcache/mmap_other.go new file mode 100644 index 0000000..6b47ec4 --- /dev/null +++ b/internal/dyldcache/mmap_other.go @@ -0,0 +1,16 @@ +//go:build !darwin && !linux + +package dyldcache + +import "os" + +// mapFile falls back to reading the whole file where mmap isn't wired up. dyld +// shared caches are a macOS/iOS artefact, so this path exists only to keep the +// package building on other platforms. +func mapFile(path string) ([]byte, func() error, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + return data, func() error { return nil }, nil +} diff --git a/internal/dyldcache/mmap_unix.go b/internal/dyldcache/mmap_unix.go new file mode 100644 index 0000000..84171f6 --- /dev/null +++ b/internal/dyldcache/mmap_unix.go @@ -0,0 +1,34 @@ +//go:build darwin || linux + +package dyldcache + +import ( + "fmt" + "os" + "syscall" +) + +// mapFile memory-maps path read-only. Shared caches are multi-gigabyte, so mmap +// (paged in on access) is essential — reading them into the heap would be +// prohibitive when the reader only touches the header, image table and a few +// image regions. +func mapFile(path string) ([]byte, func() error, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, nil, err + } + size := fi.Size() + if size <= 0 { + return nil, nil, fmt.Errorf("dyld cache %s: empty file", path) + } + data, err := syscall.Mmap(int(f.Fd()), 0, int(size), syscall.PROT_READ, syscall.MAP_PRIVATE) + if err != nil { + return nil, nil, fmt.Errorf("mmap %s: %w", path, err) + } + return data, func() error { return syscall.Munmap(data) }, nil +} diff --git a/internal/dyldcache/real_test.go b/internal/dyldcache/real_test.go new file mode 100644 index 0000000..cd750f7 --- /dev/null +++ b/internal/dyldcache/real_test.go @@ -0,0 +1,51 @@ +package dyldcache + +import ( + "os" + "testing" +) + +// realCachePaths lists where a system dyld shared cache lives across macOS +// versions. The test is skipped when none is present (e.g. CI, Linux). +var realCachePaths = []string{ + "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e", + "/System/Library/dyld/dyld_shared_cache_arm64e", + "/System/Library/dyld/dyld_shared_cache_x86_64", +} + +// TestReadRealCache exercises the reader end-to-end against the host's own dyld +// cache when one is available: every image header must translate to valid bytes. +func TestReadRealCache(t *testing.T) { + var path string + for _, p := range realCachePaths { + if _, err := os.Stat(p); err == nil { + path = p + break + } + } + if path == "" { + t.Skip("no system dyld shared cache on this host") + } + + c, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + if len(c.Images) == 0 { + t.Fatal("real cache reported no images") + } + t.Logf("arch=%s images=%d mappings=%d subcaches=%d", + c.Arch, len(c.Images), len(c.Mappings), len(c.SubCacheNames())) + + fails := 0 + for _, im := range c.Images { + if b, ok := c.BytesAt(im.Address, 4); !ok || len(b) < 4 { + fails++ + } + } + if fails > 0 { + t.Errorf("%d of %d image headers failed address translation", fails, len(c.Images)) + } +} diff --git a/internal/explorer/disasm_service.go b/internal/explorer/disasm_service.go index f804fb3..733421d 100644 --- a/internal/explorer/disasm_service.go +++ b/internal/explorer/disasm_service.go @@ -1,8 +1,10 @@ package explorer import ( + "container/list" "runtime" "sync" + "unsafe" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/disasm" @@ -17,9 +19,11 @@ type DisasmService struct { maxBytes int searchWorkers int - mu sync.RWMutex - cache map[disasmCacheKey][]disasm.Inst - order []disasmCacheKey + mu sync.RWMutex + cache map[disasmCacheKey]*list.Element + cacheLRU list.List + cacheBytes int + cacheBudget int } // disasmCacheKey identifies a decoded instruction window and its overlap start. @@ -29,15 +33,22 @@ type disasmCacheKey struct { decodeStart int } -// disasmCacheCap bounds decoded-window memory retained by the service. -const disasmCacheCap = 24 +type disasmCacheEntry struct { + key disasmCacheKey + insts []disasm.Inst + weight int +} + +// disasmCacheBudget bounds decoded-window memory retained by the service. +const disasmCacheBudget = 128 << 20 // NewDisasmService creates a bounded disassembly decoder/cache. func NewDisasmService(file *binfile.File, dis disasm.Disassembler, maxBytes, searchWorkers int) *DisasmService { s := &DisasmService{ - file: file, - dis: dis, - cache: map[disasmCacheKey][]disasm.Inst{}, + file: file, + dis: dis, + cache: map[disasmCacheKey]*list.Element{}, + cacheBudget: disasmCacheBudget, } s.SetOptions(maxBytes, searchWorkers) return s @@ -54,6 +65,19 @@ func (s *DisasmService) SetOptions(maxBytes, searchWorkers int) { s.searchWorkers = searchWorkers } +// MaxBytes returns the current decode budget per window. +func (s *DisasmService) MaxBytes() int { + maxBytes, _ := s.options() + return maxBytes +} + +// CanDecode reports whether a disassembler exists for this file's +// architecture. Every decode method is a safe no-op without one; callers that +// would otherwise mutate state on an empty decode check this first. +func (s *DisasmService) CanDecode() bool { + return s != nil && s.dis != nil +} + // OverlapBytes returns the context decoded before each visible window. func (s *DisasmService) OverlapBytes() int { maxBytes, _ := s.options() @@ -159,6 +183,22 @@ func (s *DisasmService) DecodeRange(start, size, lead int) []disasm.Inst { return s.decodeAcross(img, decodeStart, win.End, win.Start) } +// DecodeRangeFunc is DecodeRange streamed: it calls fn for each instruction in +// the range instead of returning a slice, so a scan that keeps only matching +// instructions never allocates the full decoded slice. fn returns false to stop. +func (s *DisasmService) DecodeRangeFunc(start, size, lead int, fn func(disasm.Inst) bool) { + if s == nil || s.file == nil || s.dis == nil { + return + } + img := s.file.ExecImage() + win := img.Window(start, size) + if len(win.Data) == 0 { + return + } + decodeStart := max(0, win.Start-lead) + s.decodeAcrossFunc(img, decodeStart, win.End, win.Start, fn) +} + // DecodeAt returns a window containing addr and the instructions overlapping it. func (s *DisasmService) DecodeAt(addr uint64, before int) (binfile.Window, []disasm.Inst) { if s == nil || s.file == nil || s.dis == nil { @@ -242,10 +282,23 @@ func (s *DisasmService) decodeInstWindow(win binfile.Window, decodeStart int) [] // jump. Only instructions at offset >= visibleStart are returned; the bytes // before it are decode-resync context that is dropped. func (s *DisasmService) decodeAcross(img *binfile.Image, decodeStart, end, visibleStart int) []disasm.Inst { - if img == nil || s.dis == nil || decodeStart < 0 || end > img.Len() { - end = min(end, img.Len()) - } var out []disasm.Inst + s.decodeAcrossFunc(img, decodeStart, end, visibleStart, func(in disasm.Inst) bool { + out = append(out, in) + return true + }) + return out +} + +// decodeAcrossFunc is decodeAcross streamed: it calls fn for each visible +// instruction instead of collecting them, so a whole-image scan (xref / find / +// syscalls) never materialises the multi-hundred-MB slice of every instruction — +// it keeps only the matches fn retains. fn returns false to stop early. +func (s *DisasmService) decodeAcrossFunc(img *binfile.Image, decodeStart, end, visibleStart int, fn func(disasm.Inst) bool) { + if img == nil || s.dis == nil || decodeStart < 0 { + return + } + end = min(end, img.Len()) for p := decodeStart; p < end; { r := img.RegionAt(p) if r == nil { @@ -266,7 +319,10 @@ func (s *DisasmService) decodeAcross(img *binfile.Image, decodeStart, end, visib stop = true return false } - out = append(out, in) + if !fn(in) { + stop = true + return false + } return true }) if stop { @@ -274,7 +330,6 @@ func (s *DisasmService) decodeAcross(img *binfile.Image, decodeStart, end, visib } p = regEnd } - return out } // options returns a consistent snapshot of mutable service options. @@ -285,28 +340,51 @@ func (s *DisasmService) options() (maxBytes, searchWorkers int) { return maxBytes, searchWorkers } -// cacheGet returns a cached decoded window. +// cacheGet returns a cached decoded window and marks it most recently used. func (s *DisasmService) cacheGet(key disasmCacheKey) ([]disasm.Inst, bool) { - s.mu.RLock() - insts, ok := s.cache[key] - s.mu.RUnlock() - return insts, ok + s.mu.Lock() + defer s.mu.Unlock() + elem, ok := s.cache[key] + if !ok { + return nil, false + } + s.cacheLRU.MoveToFront(elem) + return elem.Value.(*disasmCacheEntry).insts, true +} + +func disasmCacheWeight(insts []disasm.Inst) int { + // Count the retained backing array and text; Bytes refers into the mapped image. + weight := cap(insts) * int(unsafe.Sizeof(disasm.Inst{})) + for _, inst := range insts { + weight += len(inst.Text) + } + return weight } -// cachePut stores a decoded window and evicts the oldest entries over capacity. +// cachePut stores a decoded window and evicts least-recently-used entries over budget. func (s *DisasmService) cachePut(key disasmCacheKey, insts []disasm.Inst) { + weight := disasmCacheWeight(insts) s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.cache[key]; !ok { - s.order = append(s.order, key) - } - s.cache[key] = insts - // Evict oldest over capacity, compacting in place so the backing array's head - // isn't leaked by repeated reslicing. - if n := len(s.order) - disasmCacheCap; n > 0 { - for _, old := range s.order[:n] { - delete(s.cache, old) - } - s.order = append(s.order[:0], s.order[n:]...) + if weight > s.cacheBudget { + return + } + if elem, ok := s.cache[key]; ok { + entry := elem.Value.(*disasmCacheEntry) + s.cacheBytes += weight - entry.weight + entry.insts = insts + entry.weight = weight + s.cacheLRU.MoveToFront(elem) + } else { + entry := &disasmCacheEntry{key: key, insts: insts, weight: weight} + s.cache[key] = s.cacheLRU.PushFront(entry) + s.cacheBytes += weight + } + for s.cacheBytes > s.cacheBudget { + elem := s.cacheLRU.Back() + entry := elem.Value.(*disasmCacheEntry) + delete(s.cache, entry.key) + s.cacheLRU.Remove(elem) + s.cacheBytes -= entry.weight } } diff --git a/internal/explorer/disasm_test.go b/internal/explorer/disasm_test.go index 0890d3c..d8ecdf0 100644 --- a/internal/explorer/disasm_test.go +++ b/internal/explorer/disasm_test.go @@ -48,6 +48,54 @@ func TestDecodeRangeMatchesWindow(t *testing.T) { } } +// TestDecodeRangeFuncMatchesSlice guards the scan allocation win: the streamed +// DecodeRangeFunc must visit exactly the instructions DecodeRange returns, in +// order — so xref/find/scan results are identical to the slice path. +func TestDecodeRangeFuncMatchesSlice(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Skip(err) + } + f, err := binfile.Open(exe) + if err != nil { + t.Skip(err) + } + defer f.Close() + dis, err := disasm.For(f.Arch()) + if err != nil || dis == nil { + t.Skip("no disassembler for host arch") + } + img := f.ExecImage() + size := 8192 + if img.Len() < 4*size { + t.Skip("exec image too small") + } + start := (img.Len() / 2) &^ 0xfff + s := NewDisasmService(f, dis, 2<<20, 0) + + want := s.DecodeRange(start, size, 1<<10) + var got []disasm.Inst + s.DecodeRangeFunc(start, size, 1<<10, func(in disasm.Inst) bool { + got = append(got, in) + return true + }) + if len(want) == 0 || len(want) != len(got) { + t.Fatalf("instruction count: DecodeRange=%d DecodeRangeFunc=%d", len(want), len(got)) + } + for i := range want { + if want[i].Addr != got[i].Addr || want[i].Text != got[i].Text { + t.Fatalf("inst %d differs: 0x%x %q vs 0x%x %q", i, want[i].Addr, want[i].Text, got[i].Addr, got[i].Text) + } + } + + // Early stop: returning false halts iteration. + n := 0 + s.DecodeRangeFunc(start, size, 1<<10, func(disasm.Inst) bool { n++; return n < 3 }) + if n != 3 { + t.Fatalf("early stop visited %d instructions, want 3", n) + } +} + func TestDisasmSearchWorkerPolicy(t *testing.T) { s := NewDisasmService(nil, nil, 256<<10, 3) if got := s.SearchWorkersFor(10); got != 3 { @@ -63,6 +111,10 @@ func TestDisasmSearchWorkerPolicy(t *testing.T) { if got := s.SearchBatchChunks(); got < 4 { t.Fatalf("small-window batch chunks = %d, want at least 4", got) } + s.SetOptions(256<<10, 0) + if got := s.SearchWorkersFor(100); got < 1 || got > 6 { + t.Fatalf("default workers = %d, want between 1 and 6", got) + } } func TestDisasmLeadAndOverlapStayWithinBudget(t *testing.T) { @@ -74,3 +126,71 @@ func TestDisasmLeadAndOverlapStayWithinBudget(t *testing.T) { t.Fatalf("lead = %d, overlap = %d", got, s.OverlapBytes()) } } + +func TestDisasmCacheEvictsByRetainedBytes(t *testing.T) { + s := NewDisasmService(nil, nil, 1, 0) + light := []disasm.Inst{{Text: "x"}} + heavy := []disasm.Inst{{Text: "xx"}} + s.cacheBudget = disasmCacheWeight(light) * 2 + + a := disasmCacheKey{start: 1} + b := disasmCacheKey{start: 2} + c := disasmCacheKey{start: 3} + s.cachePut(a, light) + s.cachePut(b, light) + s.cachePut(c, heavy) + + if _, ok := s.cacheGet(a); ok { + t.Fatal("oldest entry remained cached after byte eviction") + } + if _, ok := s.cacheGet(b); ok { + t.Fatal("second entry remained cached despite unequal entry weights") + } + if _, ok := s.cacheGet(c); !ok { + t.Fatal("new entry was not cached") + } + if s.cacheBytes != disasmCacheWeight(heavy) || len(s.cache) != 1 { + t.Fatalf("cache retained %d bytes in %d entries, budget %d", s.cacheBytes, len(s.cache), s.cacheBudget) + } +} + +func TestDisasmCacheRejectsOversizedEntry(t *testing.T) { + s := NewDisasmService(nil, nil, 1, 0) + s.cacheBudget = disasmCacheWeight([]disasm.Inst{{}}) + key := disasmCacheKey{start: 1} + s.cachePut(key, []disasm.Inst{{Text: "too large"}}) + + if _, ok := s.cacheGet(key); ok { + t.Fatal("oversized entry entered cache") + } + if s.cacheBytes != 0 || len(s.cache) != 0 { + t.Fatalf("cache retained oversized entry: %d bytes in %d entries", s.cacheBytes, len(s.cache)) + } +} + +func TestDisasmCacheHitUpdatesRecency(t *testing.T) { + s := NewDisasmService(nil, nil, 1, 0) + insts := []disasm.Inst{{Text: "x"}} + weight := disasmCacheWeight(insts) + s.cacheBudget = weight * 2 + + a := disasmCacheKey{start: 1} + b := disasmCacheKey{start: 2} + c := disasmCacheKey{start: 3} + s.cachePut(a, insts) + s.cachePut(b, insts) + if _, ok := s.cacheGet(a); !ok { + t.Fatal("expected first entry in cache") + } + s.cachePut(c, insts) + + if _, ok := s.cacheGet(b); ok { + t.Fatal("least recently used entry remained cached") + } + if _, ok := s.cacheGet(a); !ok { + t.Fatal("recently accessed entry was evicted") + } + if _, ok := s.cacheGet(c); !ok { + t.Fatal("new entry was not cached") + } +} diff --git a/internal/explorer/scan.go b/internal/explorer/scan.go new file mode 100644 index 0000000..d21ab47 --- /dev/null +++ b/internal/explorer/scan.go @@ -0,0 +1,173 @@ +package explorer + +import ( + "container/heap" + "sort" + "strings" + "sync" + + "github.com/rabarbra/exex/internal/disasm" +) + +// scanLead is the resync context decoded before each scan chunk; small (vs the +// interactive overlap) since chunks are contiguous, and a multiple of 4 to keep +// arm64/riscv instruction alignment. +const scanLead = 1 << 10 + +// Match is one instruction a whole-image scan kept. +type Match struct { + Addr uint64 // address of the matching instruction + Text string // its (trimmed) assembly text + Sym string // display name of the symbol it lives in, or "" +} + +// ScanMatching decodes the executable image in parallel chunks and returns the +// `limit` lowest-addressed instructions whose text satisfies match. It stops +// early when done is closed. +// +// Retained memory is bounded by limit, not by the number of matches. Each worker +// used to accumulate *every* match in its chunk, and the cap was applied only +// after all of them had been joined and sorted: searching "mov" over exex's own +// 15 MB binary matches ~98,000 instructions, so ~10 MB was allocated (and sorted) +// to show 500 rows — and it grew linearly with the target's size. +// +// Bounding it also fixes what the cap discarded. The old merge stopped after the +// first chunks filled the quota, so matches in later chunks were dropped before +// the sort even looked at them; a low-addressed hit late in the image lost to a +// high-addressed one early in it. The shared heap below compares every match, so +// the result is exactly the lowest `limit` by address. +func (s *DisasmService) ScanMatching(match func(text string) bool, limit int, done <-chan struct{}) []Match { + if s == nil || s.file == nil || s.dis == nil || limit <= 0 { + return nil + } + img := s.file.ExecImage() + chunk := s.SearchChunkBytes() + + var starts []int + for pos := 0; pos < img.Len(); { + win := img.Window(pos, chunk) + if len(win.Data) == 0 || win.End <= pos { + break + } + starts = append(starts, pos) + pos = win.End + } + workers := s.SearchWorkersFor(len(starts)) + sem := make(chan struct{}, workers) + + // One heap for all workers: a per-worker cap would still scale with the chunk + // count. The lock is taken only on a match, which is rare next to the decode. + var mu sync.Mutex + best := make(lowestMatches, 0, limit) + + var wg sync.WaitGroup + for _, start := range starts { + if cancelled(done) { + break + } + wg.Add(1) + sem <- struct{}{} + go func(start int) { + defer wg.Done() + defer func() { <-sem }() + if cancelled(done) { + return + } + s.DecodeRangeFunc(start, chunk, scanLead, func(inst disasm.Inst) bool { + if cancelled(done) { + return false + } + if !match(inst.Text) { + return true + } + // Check the threshold before building the match, so a saturated scan + // stops calling SymbolAt and trimming text for hits it will discard. + mu.Lock() + if !best.wants(inst.Addr, limit) { + mu.Unlock() + return true + } + mu.Unlock() + + sym := "" + if sm, ok := s.file.SymbolAt(inst.Addr); ok { + sym = sm.Display() + } + hit := Match{Addr: inst.Addr, Text: strings.TrimSpace(inst.Text), Sym: sym} + + mu.Lock() + best.push(hit, limit) + mu.Unlock() + return true + }) + }(start) + } + wg.Wait() + + hits := []Match(best) + sort.Slice(hits, func(i, j int) bool { return hits[i].Addr < hits[j].Addr }) + return dedupeByAddr(hits) +} + +// cancelled reports whether the caller closed done. +func cancelled(done <-chan struct{}) bool { + if done == nil { + return false + } + select { + case <-done: + return true + default: + return false + } +} + +// dedupeByAddr drops repeated addresses from an address-sorted slice. Chunks +// cover disjoint offset ranges, so a duplicate needs two offsets mapping to one +// address (overlapping regions in a sparse image) — rare, and cheap to handle +// here rather than by retaining a set of every match seen. +func dedupeByAddr(hits []Match) []Match { + out := hits[:0] + for i, h := range hits { + if i > 0 && h.Addr == hits[i-1].Addr { + continue + } + out = append(out, h) + } + return out +} + +// lowestMatches is a max-heap by address holding the lowest-addressed matches +// seen so far, so the largest — the next to be evicted — sits at index 0. It +// never grows beyond the caller's limit. +type lowestMatches []Match + +func (h lowestMatches) Len() int { return len(h) } +func (h lowestMatches) Less(i, j int) bool { return h[i].Addr > h[j].Addr } +func (h lowestMatches) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *lowestMatches) Push(x any) { *h = append(*h, x.(Match)) } +func (h *lowestMatches) Pop() any { + old := *h + n := len(old) - 1 + it := old[n] + *h = old[:n] + return it +} + +// wants reports whether addr would be kept: either the heap has room, or addr +// beats the worst match currently held. +func (h lowestMatches) wants(addr uint64, limit int) bool { + return len(h) < limit || addr < h[0].Addr +} + +// push inserts hit, evicting the highest-addressed match once at limit. +func (h *lowestMatches) push(hit Match, limit int) { + if len(*h) < limit { + heap.Push(h, hit) + return + } + if hit.Addr < (*h)[0].Addr { + (*h)[0] = hit + heap.Fix(h, 0) + } +} diff --git a/internal/explorer/scan_integration_test.go b/internal/explorer/scan_integration_test.go new file mode 100644 index 0000000..d487f1d --- /dev/null +++ b/internal/explorer/scan_integration_test.go @@ -0,0 +1,122 @@ +package explorer_test + +import ( + "strings" + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/explorer" + "github.com/rabarbra/exex/internal/testbin" +) + +// newFixtureService decodes internal/testbin's hand-built ELF, whose .text is a +// known instruction sequence: +// +// 0x401000 mov $0x1,%rax +// 0x401007 mov $0x1,%rdi +// 0x40100e call 0x401020 +// 0x401013 syscall +// 0x401015 ret +// nop × 10 +// 0x401020 push %rbp (helper) +// ... +func newFixtureService(t *testing.T) *explorer.DisasmService { + t.Helper() + f, err := binfile.Open(testbin.WriteTinyELF64(t)) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { f.Close() }) + d, err := disasm.For(f.Arch()) + if err != nil { + t.Fatalf("disassembler: %v", err) + } + return explorer.NewDisasmService(f, d, 1<<20, 0) +} + +func addrs(ms []explorer.Match) []uint64 { + out := make([]uint64, len(ms)) + for i, m := range ms { + out[i] = m.Addr + } + return out +} + +// TestScanMatchingFindsInstructions walks the real decode path end to end. +func TestScanMatchingFindsInstructions(t *testing.T) { + svc := newFixtureService(t) + + got := svc.ScanMatching(func(text string) bool { + return strings.Contains(text, "syscall") + }, 500, nil) + if len(got) != 1 { + t.Fatalf("syscall matches = %d, want 1: %v", len(got), addrs(got)) + } + if got[0].Addr != 0x401013 { + t.Errorf("syscall at 0x%x, want 0x401013", got[0].Addr) + } + if got[0].Sym != "_start" { + t.Errorf("syscall symbol = %q, want _start", got[0].Sym) + } + if strings.TrimSpace(got[0].Text) != got[0].Text { + t.Errorf("match text is not trimmed: %q", got[0].Text) + } +} + +// TestScanMatchingReturnsAscendingAddresses: the modal renders the list in +// address order and its caller relies on that, not on chunk arrival order. +func TestScanMatchingReturnsAscendingAddresses(t *testing.T) { + svc := newFixtureService(t) + got := svc.ScanMatching(func(string) bool { return true }, 500, nil) + if len(got) < 5 { + t.Fatalf("only %d instructions decoded", len(got)) + } + for i := 1; i < len(got); i++ { + if got[i-1].Addr >= got[i].Addr { + t.Fatalf("addresses not strictly ascending at %d: 0x%x then 0x%x", i, got[i-1].Addr, got[i].Addr) + } + } +} + +// TestScanMatchingHonoursLimit: the cap keeps the *lowest* addresses, so a +// limited scan is a prefix of the unlimited one. +func TestScanMatchingHonoursLimit(t *testing.T) { + svc := newFixtureService(t) + all := svc.ScanMatching(func(string) bool { return true }, 500, nil) + if len(all) < 4 { + t.Fatalf("only %d instructions decoded", len(all)) + } + got := svc.ScanMatching(func(string) bool { return true }, 3, nil) + if len(got) != 3 { + t.Fatalf("limit 3 returned %d matches", len(got)) + } + for i := range got { + if got[i].Addr != all[i].Addr { + t.Errorf("limited[%d] = 0x%x, want 0x%x (the lowest addresses)", i, got[i].Addr, all[i].Addr) + } + } +} + +func TestScanMatchingEdgeCases(t *testing.T) { + svc := newFixtureService(t) + + if got := svc.ScanMatching(func(string) bool { return true }, 0, nil); got != nil { + t.Errorf("limit 0 returned %d matches, want nil", len(got)) + } + if got := svc.ScanMatching(func(string) bool { return false }, 500, nil); len(got) != 0 { + t.Errorf("matching nothing returned %d matches", len(got)) + } + + // A cancelled scan must not block or return partial garbage. + done := make(chan struct{}) + close(done) + if got := svc.ScanMatching(func(string) bool { return true }, 500, done); len(got) != 0 { + t.Errorf("pre-cancelled scan returned %d matches", len(got)) + } + + var nilSvc *explorer.DisasmService + if got := nilSvc.ScanMatching(func(string) bool { return true }, 500, nil); got != nil { + t.Error("nil service should return nil") + } +} diff --git a/internal/explorer/scan_test.go b/internal/explorer/scan_test.go new file mode 100644 index 0000000..ad38c62 --- /dev/null +++ b/internal/explorer/scan_test.go @@ -0,0 +1,139 @@ +package explorer + +import ( + "math/rand" + "slices" + "testing" +) + +// pushAll feeds addrs through the bounded heap the way ScanMatching does: +// consult wants first, then push. +func pushAll(limit int, addrs []uint64) lowestMatches { + best := make(lowestMatches, 0, limit) + for _, a := range addrs { + if !best.wants(a, limit) { + continue + } + best.push(Match{Addr: a}, limit) + } + return best +} + +func sortedAddrs(h lowestMatches) []uint64 { + out := make([]uint64, len(h)) + for i, hit := range h { + out[i] = hit.Addr + } + slices.Sort(out) + return out +} + +// TestLowestMatchesNeverExceedsLimit is the memory bound: retained matches are +// capped by limit, not by the number of matches. ScanMatching used to accumulate +// every match (~98k for "mov" over a 15 MB binary) before truncating to 500. +func TestLowestMatchesNeverExceedsLimit(t *testing.T) { + const limit = 500 + best := make(lowestMatches, 0, limit) + rng := rand.New(rand.NewSource(1)) + for range 100_000 { + a := rng.Uint64() + if !best.wants(a, limit) { + continue + } + best.push(Match{Addr: a}, limit) + if len(best) > limit { + t.Fatalf("heap grew to %d, limit %d", len(best), limit) + } + } + if len(best) != limit { + t.Errorf("heap holds %d matches, want %d", len(best), limit) + } + if cap(best) != limit { + t.Errorf("heap reallocated: cap %d, want %d", cap(best), limit) + } +} + +// TestLowestMatchesKeepsGloballyLowest is the correctness fix. The old merge +// truncated in chunk-arrival order before sorting, so a low address discovered +// late lost to a high address discovered early. Feeding descending addresses +// (worst case for arrival order) must still yield the lowest `limit`. +func TestLowestMatchesKeepsGloballyLowest(t *testing.T) { + const limit = 10 + var descending []uint64 + for a := uint64(100); a > 0; a-- { + descending = append(descending, a) + } + got := sortedAddrs(pushAll(limit, descending)) + for i, a := range got { + if want := uint64(i + 1); a != want { + t.Fatalf("addr[%d] = %d, want %d (heap did not keep the globally lowest)", i, a, want) + } + } +} + +// TestLowestMatchesOrderIndependent: the result must not depend on the order +// workers happen to report matches in, which is nondeterministic across chunks. +func TestLowestMatchesOrderIndependent(t *testing.T) { + const limit = 50 + addrs := make([]uint64, 1000) + for i := range addrs { + addrs[i] = uint64(i) * 7 % 1000 + } + want := sortedAddrs(pushAll(limit, addrs)) + + rng := rand.New(rand.NewSource(7)) + for trial := range 20 { + shuffled := slices.Clone(addrs) + rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + got := sortedAddrs(pushAll(limit, shuffled)) + for i := range want { + if got[i] != want[i] { + t.Fatalf("trial %d: addr[%d] = %d, want %d", trial, i, got[i], want[i]) + } + } + } +} + +func TestDedupeByAddr(t *testing.T) { + for _, tc := range []struct { + name string + in []uint64 + want []uint64 + }{ + {"empty", nil, nil}, + {"no dupes", []uint64{1, 2, 3}, []uint64{1, 2, 3}}, + {"adjacent dupes", []uint64{1, 1, 2, 2, 2, 3}, []uint64{1, 2, 3}}, + {"all same", []uint64{4, 4, 4}, []uint64{4}}, + } { + t.Run(tc.name, func(t *testing.T) { + hits := make([]Match, len(tc.in)) + for i, a := range tc.in { + hits[i] = Match{Addr: a} + } + got := dedupeByAddr(hits) + if len(got) != len(tc.want) { + t.Fatalf("got %d matches, want %d", len(got), len(tc.want)) + } + for i, h := range got { + if h.Addr != tc.want[i] { + t.Errorf("addr[%d] = %d, want %d", i, h.Addr, tc.want[i]) + } + } + }) + } +} + +func TestCancelled(t *testing.T) { + if cancelled(nil) { + t.Error("nil done reported cancelled") + } + open := make(chan struct{}) + if cancelled(open) { + t.Error("open done reported cancelled") + } + closed := make(chan struct{}) + close(closed) + if !cancelled(closed) { + t.Error("closed done not reported cancelled") + } +} diff --git a/internal/explorer/searchcache.go b/internal/explorer/searchcache.go new file mode 100644 index 0000000..448a647 --- /dev/null +++ b/internal/explorer/searchcache.go @@ -0,0 +1,201 @@ +package explorer + +import "sort" + +// SearchCache remembers where an instruction-text search has already found hits, +// and how much of the executable image it has already scanned, for one query. +// +// The disassembly search scans the image in chunks, streaming results back as it +// goes. Repeating the search (n / N) must not rescan: the cache answers "the next +// hit after this address" directly whenever it can, and tracks the scanned range +// so it knows when it holds *every* hit and can stop scanning altogether. +// +// It is bounded. Past CacheCap hits it records that it overflowed and keeps only +// the window nearest the direction of travel, because a query like "mov" matches +// tens of thousands of instructions and the whole point is not to hold them. + +// CacheCap bounds how many hits one query's cache retains. +const CacheCap = 100 + +// SearchHit is a cached match: only the address and the instruction text. +// +// Deliberately *not* the decoded window that found it. Retaining those would pin +// a decoded chunk per hit; a repeat jump re-decodes around the address instead, +// which the service's window cache usually satisfies anyway. +type SearchHit struct { + Addr uint64 + Text string +} + +// CursorMode says where the search cursor sits relative to the hits, which +// decides what "the next one" means when the cursor has run off an end. +type CursorMode uint8 + +const ( + CursorAtMatch CursorMode = iota // on a hit (or anywhere in the image) + CursorAfterEnd // ran off the end going forward + CursorBeforeStart // ran off the start going backward +) + +// SearchCache is the per-query cache. The zero value is an empty cache for the +// empty query; call Reset before use. +type SearchCache struct { + query string + hits []SearchHit + forwardExhausted bool + backwardExhausted bool + scannedLo int // -1 until anything has been scanned + scannedHi int + overflow bool +} + +// Reset empties the cache for a new query. +func (c *SearchCache) Reset(query string) { + *c = SearchCache{query: query, scannedLo: -1} +} + +// EnsureQuery resets the cache when the query has changed, and reports whether it +// did. +func (c *SearchCache) EnsureQuery(query string) bool { + if c.query != query { + c.Reset(query) + return true + } + return false +} + +// Query returns the query this cache holds hits for. +func (c *SearchCache) Query() string { return c.query } + +// Hits returns the cached hits, in ascending address order. +func (c *SearchCache) Hits() []SearchHit { return c.hits } + +// Overflow reports whether more hits were found than the cache can hold, so it +// no longer holds all of them. +func (c *SearchCache) Overflow() bool { return c.overflow } + +// Exhausted reports whether scanning in this direction has reached the image's +// end without finding anything more. +func (c *SearchCache) Exhausted(forward bool) bool { + if forward { + return c.forwardExhausted + } + return c.backwardExhausted +} + +// SetExhausted records that a direction has been scanned to its end. +func (c *SearchCache) SetExhausted(forward bool) { + if forward { + c.forwardExhausted = true + } else { + c.backwardExhausted = true + } +} + +// Add merges freshly found hits, keeping the slice sorted and deduplicated by +// address. Past CacheCap it trims to the window nearest the direction of travel: +// forward keeps the highest addresses, backward the lowest. +// +// Add reorders hits in place. +func (c *SearchCache) Add(hits []SearchHit, forward bool) { + if len(hits) == 0 { + return + } + // Sorting first is not needed for correctness — the insert below is a binary + // search either way — but it makes every insert land at the end of c.hits, + // where the shift is free. Parallel chunk workers report hits in arbitrary + // order, and a query like "mov" reports thousands at a time; without this the + // insert degrades to a memmove per hit (~23x slower at 10k). + sort.Slice(hits, func(i, j int) bool { return hits[i].Addr < hits[j].Addr }) + for _, hit := range hits { + i := sort.Search(len(c.hits), func(i int) bool { return c.hits[i].Addr >= hit.Addr }) + if i < len(c.hits) && c.hits[i].Addr == hit.Addr { + continue + } + if len(c.hits) >= CacheCap { + c.overflow = true + } + c.hits = append(c.hits, SearchHit{}) + copy(c.hits[i+1:], c.hits[i:]) + c.hits[i] = hit + } + if len(c.hits) > CacheCap { + if forward { + c.hits = c.hits[len(c.hits)-CacheCap:] + } else { + c.hits = c.hits[:CacheCap] + } + } +} + +// NoteCoverage widens the scanned image range. +func (c *SearchCache) NoteCoverage(lo, hi int) { + if lo < 0 { + lo = 0 + } + if hi < lo { + hi = lo + } + if c.scannedLo < 0 || lo < c.scannedLo { + c.scannedLo = lo + } + if hi > c.scannedHi { + c.scannedHi = hi + } +} + +// Coverage returns the scanned image range; lo is -1 when nothing was scanned. +func (c *SearchCache) Coverage() (lo, hi int) { return c.scannedLo, c.scannedHi } + +// Complete reports whether the cache holds every hit in an image of imgLen bytes: +// the whole image was scanned and nothing was dropped. +func (c *SearchCache) Complete(imgLen int) bool { + return !c.overflow && c.scannedLo == 0 && c.scannedHi >= imgLen +} + +// Next returns the cached hit the search would move to from cur, or ok=false when +// the cache cannot answer and the image must be scanned. +// +// inclusive keeps a hit exactly at cur (the initial Enter); n / N pass false to +// step past it. When the cursor has run off an end, the search wraps to the hit +// at the opposite end. +func (c *SearchCache) Next(cur uint64, mode CursorMode, forward, inclusive bool) (SearchHit, bool) { + if len(c.hits) == 0 { + return SearchHit{}, false + } + if !forward && mode == CursorAfterEnd { + return c.hits[len(c.hits)-1], true + } + if forward && mode == CursorBeforeStart { + return c.hits[0], true + } + if forward { + for _, hit := range c.hits { + if (!inclusive && hit.Addr <= cur) || (inclusive && hit.Addr < cur) { + continue + } + return hit, true + } + return SearchHit{}, false + } + for i := len(c.hits) - 1; i >= 0; i-- { + hit := c.hits[i] + if (!inclusive && hit.Addr >= cur) || (inclusive && hit.Addr > cur) { + continue + } + return hit, true + } + return SearchHit{}, false +} + +// Boundary returns the outermost cached hit in a direction: the last one going +// forward, the first going backward. It is where an incremental scan resumes. +func (c *SearchCache) Boundary(forward bool) (uint64, bool) { + if len(c.hits) == 0 { + return 0, false + } + if forward { + return c.hits[len(c.hits)-1].Addr, true + } + return c.hits[0].Addr, true +} diff --git a/internal/explorer/searchcache_test.go b/internal/explorer/searchcache_test.go new file mode 100644 index 0000000..0906f4b --- /dev/null +++ b/internal/explorer/searchcache_test.go @@ -0,0 +1,392 @@ +package explorer_test + +import ( + "testing" + + "github.com/rabarbra/exex/internal/explorer" +) + +// hits builds a cache pre-loaded with the given addresses, in one Add. +func hitsAt(addrs ...uint64) []explorer.SearchHit { + out := make([]explorer.SearchHit, len(addrs)) + for i, a := range addrs { + out[i] = explorer.SearchHit{Addr: a, Text: "mov"} + } + return out +} + +func hitAddrs(hs []explorer.SearchHit) []uint64 { + out := make([]uint64, len(hs)) + for i, h := range hs { + out[i] = h.Addr + } + return out +} + +func sameAddrs(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestResetClearsEverything(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(1, 2), true) + c.NoteCoverage(0, 100) + c.SetExhausted(true) + + c.Reset("call") + if c.Query() != "call" { + t.Errorf("Query() = %q, want %q", c.Query(), "call") + } + if len(c.Hits()) != 0 { + t.Errorf("Reset kept %d hits", len(c.Hits())) + } + if c.Exhausted(true) || c.Exhausted(false) { + t.Error("Reset kept an exhaustion flag") + } + // scannedLo must go back to -1, not 0: 0 means "the image start was scanned", + // which would let Complete() pass over an unscanned image. + if lo, hi := c.Coverage(); lo != -1 || hi != 0 { + t.Errorf("Coverage() = (%d,%d), want (-1,0)", lo, hi) + } + if c.Complete(1000) { + t.Error("a freshly reset cache reported Complete") + } +} + +func TestEnsureQueryResetsOnlyOnChange(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(1, 2), true) + + if c.EnsureQuery("mov") { + t.Error("EnsureQuery reported a reset for the same query") + } + if len(c.Hits()) != 2 { + t.Fatalf("the same query dropped hits: %d", len(c.Hits())) + } + if !c.EnsureQuery("call") { + t.Error("EnsureQuery did not report a reset for a new query") + } + if len(c.Hits()) != 0 { + t.Errorf("a new query kept %d hits", len(c.Hits())) + } +} + +// TestAddSortsAndDedupes: chunks arrive out of order and overlap at their seams, +// so the same address can be reported twice. +func TestAddSortsAndDedupes(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(0x30, 0x10, 0x20), true) + c.Add(hitsAt(0x20, 0x05, 0x40), true) // 0x20 is a duplicate from the seam + + want := []uint64{0x05, 0x10, 0x20, 0x30, 0x40} + if got := hitAddrs(c.Hits()); !sameAddrs(got, want) { + t.Errorf("Hits() = %#x, want %#x", got, want) + } + if c.Overflow() { + t.Error("five hits overflowed a cache of 100") + } +} + +func TestAddEmptyIsANoop(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(nil, true) + if len(c.Hits()) != 0 || c.Overflow() { + t.Error("adding nothing changed the cache") + } +} + +// TestOverflowIsSetOnlyPastTheCap: exactly CacheCap distinct hits still hold +// *every* hit, so the cache is complete and Complete() may return true. +func TestOverflowIsSetOnlyPastTheCap(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + full := make([]uint64, explorer.CacheCap) + for i := range full { + full[i] = uint64(i + 1) + } + c.Add(hitsAt(full...), true) + if c.Overflow() { + t.Fatalf("exactly %d hits set the overflow flag", explorer.CacheCap) + } + if len(c.Hits()) != explorer.CacheCap { + t.Fatalf("kept %d hits, want %d", len(c.Hits()), explorer.CacheCap) + } + + // A duplicate is not a new hit and must not trip overflow. + c.Add(hitsAt(1), true) + if c.Overflow() { + t.Error("re-adding a cached address set the overflow flag") + } + + c.Add(hitsAt(uint64(explorer.CacheCap+1)), true) + if !c.Overflow() { + t.Error("exceeding the cap did not set the overflow flag") + } +} + +// TestAddTrimsTowardTheDirectionOfTravel is why Add takes `forward`: past the cap +// the useful hits are the ones the user is walking toward. +func TestAddTrimsTowardTheDirectionOfTravel(t *testing.T) { + n := explorer.CacheCap + 10 + all := make([]uint64, n) + for i := range all { + all[i] = uint64(i + 1) // 1 .. cap+10 + } + + t.Run("forward keeps the highest addresses", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(all...), true) + got := hitAddrs(c.Hits()) + if len(got) != explorer.CacheCap { + t.Fatalf("kept %d hits, want %d", len(got), explorer.CacheCap) + } + if got[0] != 11 || got[len(got)-1] != uint64(n) { + t.Errorf("kept [%d..%d], want the top window [11..%d]", got[0], got[len(got)-1], n) + } + }) + + t.Run("backward keeps the lowest addresses", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(all...), false) + got := hitAddrs(c.Hits()) + if len(got) != explorer.CacheCap { + t.Fatalf("kept %d hits, want %d", len(got), explorer.CacheCap) + } + if got[0] != 1 || got[len(got)-1] != uint64(explorer.CacheCap) { + t.Errorf("kept [%d..%d], want the bottom window [1..%d]", got[0], got[len(got)-1], explorer.CacheCap) + } + }) +} + +func TestNoteCoverageWidensAndClamps(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + + c.NoteCoverage(100, 200) + if lo, hi := c.Coverage(); lo != 100 || hi != 200 { + t.Fatalf("Coverage() = (%d,%d), want (100,200)", lo, hi) + } + // A narrower range must not shrink the covered span. + c.NoteCoverage(120, 180) + if lo, hi := c.Coverage(); lo != 100 || hi != 200 { + t.Errorf("a narrower range shrank coverage to (%d,%d)", lo, hi) + } + c.NoteCoverage(50, 300) + if lo, hi := c.Coverage(); lo != 50 || hi != 300 { + t.Errorf("Coverage() = (%d,%d), want (50,300)", lo, hi) + } + // A chunk's lead-in can start before the image; it clamps rather than + // poisoning scannedLo with a negative. + c.NoteCoverage(-64, 10) + if lo, _ := c.Coverage(); lo != 0 { + t.Errorf("negative lo left scannedLo = %d, want 0", lo) + } + // An inverted range collapses to a point instead of moving hi backwards. + c.NoteCoverage(400, 350) + if _, hi := c.Coverage(); hi != 400 { + t.Errorf("inverted range left scannedHi = %d, want 400", hi) + } +} + +// TestCompleteNeedsTheWholeImageAndNoDrops: Complete() is what lets a repeat +// search skip scanning entirely, so every one of its three conditions matters. +func TestCompleteNeedsTheWholeImageAndNoDrops(t *testing.T) { + const imgLen = 1000 + + t.Run("whole image, nothing dropped", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.NoteCoverage(0, imgLen) + if !c.Complete(imgLen) { + t.Error("a fully scanned, non-overflowing cache is not Complete") + } + }) + + t.Run("a scan that never reached the image start", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.NoteCoverage(1, imgLen) + if c.Complete(imgLen) { + t.Error("Complete ignored the unscanned prefix") + } + }) + + t.Run("a scan that never reached the image end", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.NoteCoverage(0, imgLen-1) + if c.Complete(imgLen) { + t.Error("Complete ignored the unscanned suffix") + } + }) + + t.Run("a full scan that dropped hits", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.NoteCoverage(0, imgLen) + over := make([]uint64, explorer.CacheCap+1) + for i := range over { + over[i] = uint64(i + 1) + } + c.Add(hitsAt(over...), true) + if !c.Overflow() { + t.Fatal("the setup did not overflow") + } + if c.Complete(imgLen) { + t.Error("an overflowed cache reported Complete; a repeat search would skip real hits") + } + }) + + t.Run("nothing scanned", func(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + if c.Complete(imgLen) { + t.Error("an unscanned cache reported Complete") + } + // The empty image is the one case where scanning nothing suffices, but only + // once coverage has actually been recorded. + if c.Complete(0) { + t.Error("scannedLo == -1 reported Complete even for an empty image") + } + }) +} + +func TestExhaustedIsPerDirection(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + if c.Exhausted(true) || c.Exhausted(false) { + t.Fatal("a fresh cache is exhausted") + } + c.SetExhausted(true) + if !c.Exhausted(true) { + t.Error("forward exhaustion did not stick") + } + if c.Exhausted(false) { + t.Error("forward exhaustion leaked into the backward direction") + } + c.SetExhausted(false) + if !c.Exhausted(false) { + t.Error("backward exhaustion did not stick") + } +} + +func TestNextOnAnEmptyCache(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + for _, forward := range []bool{true, false} { + if _, ok := c.Next(0x100, explorer.CursorAtMatch, forward, true); ok { + t.Errorf("forward=%v: an empty cache answered Next", forward) + } + } +} + +// TestNextFromAMatch covers the interesting axis: `inclusive` decides whether a +// hit sitting exactly on the cursor counts. The initial Enter says yes; n and N +// say no, so they step off the current match instead of finding it again. +func TestNextFromAMatch(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(0x10, 0x20, 0x30), true) + + for _, tc := range []struct { + name string + cur uint64 + forward bool + inclusive bool + want uint64 + wantOK bool + }{ + {"forward, inclusive, on a hit stays put", 0x20, true, true, 0x20, true}, + {"forward, exclusive, on a hit steps past", 0x20, true, false, 0x30, true}, + {"forward, between hits", 0x18, true, false, 0x20, true}, + {"forward, past the last hit", 0x30, true, false, 0, false}, + {"forward, inclusive on the last hit", 0x30, true, true, 0x30, true}, + {"forward, before the first hit", 0x00, true, false, 0x10, true}, + + {"backward, inclusive, on a hit stays put", 0x20, false, true, 0x20, true}, + {"backward, exclusive, on a hit steps past", 0x20, false, false, 0x10, true}, + {"backward, between hits", 0x28, false, false, 0x20, true}, + {"backward, before the first hit", 0x10, false, false, 0, false}, + {"backward, inclusive on the first hit", 0x10, false, true, 0x10, true}, + {"backward, past the last hit", 0x99, false, false, 0x30, true}, + } { + t.Run(tc.name, func(t *testing.T) { + hit, ok := c.Next(tc.cur, explorer.CursorAtMatch, tc.forward, tc.inclusive) + if ok != tc.wantOK || (ok && hit.Addr != tc.want) { + t.Errorf("Next(%#x, forward=%v, inclusive=%v) = (%#x, %v), want (%#x, %v)", + tc.cur, tc.forward, tc.inclusive, hit.Addr, ok, tc.want, tc.wantOK) + } + }) + } +} + +// TestNextWrapsWhenTheCursorRanOffAnEnd: after a forward search hit the end, +// searching backward must land on the last hit rather than measuring against a +// cursor address that no longer means anything. This is the whole reason +// CursorMode exists. +func TestNextWrapsWhenTheCursorRanOffAnEnd(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + c.Add(hitsAt(0x10, 0x20, 0x30), true) + + t.Run("after the end, backward lands on the last hit", func(t *testing.T) { + // cur is deliberately below every hit: were the mode ignored, the exclusive + // backward walk would find nothing. + hit, ok := c.Next(0x00, explorer.CursorAfterEnd, false, false) + if !ok || hit.Addr != 0x30 { + t.Errorf("Next = (%#x, %v), want (0x30, true)", hit.Addr, ok) + } + }) + + t.Run("before the start, forward lands on the first hit", func(t *testing.T) { + hit, ok := c.Next(0x99, explorer.CursorBeforeStart, true, false) + if !ok || hit.Addr != 0x10 { + t.Errorf("Next = (%#x, %v), want (0x10, true)", hit.Addr, ok) + } + }) + + // The mode only overrides the *opposite* direction. Continuing forward after + // running off the end still finds nothing, which is what makes the search + // report "not found" rather than silently wrapping. + t.Run("after the end, forward still finds nothing", func(t *testing.T) { + if hit, ok := c.Next(0x30, explorer.CursorAfterEnd, true, false); ok { + t.Errorf("Next found %#x past the end", hit.Addr) + } + }) + + t.Run("before the start, backward still finds nothing", func(t *testing.T) { + if hit, ok := c.Next(0x10, explorer.CursorBeforeStart, false, false); ok { + t.Errorf("Next found %#x before the start", hit.Addr) + } + }) +} + +func TestBoundary(t *testing.T) { + var c explorer.SearchCache + c.Reset("mov") + if _, ok := c.Boundary(true); ok { + t.Error("an empty cache has a boundary") + } + c.Add(hitsAt(0x30, 0x10, 0x20), true) + + if got, ok := c.Boundary(true); !ok || got != 0x30 { + t.Errorf("Boundary(forward) = (%#x, %v), want (0x30, true)", got, ok) + } + if got, ok := c.Boundary(false); !ok || got != 0x10 { + t.Errorf("Boundary(backward) = (%#x, %v), want (0x10, true)", got, ok) + } +} diff --git a/internal/explorer/span.go b/internal/explorer/span.go new file mode 100644 index 0000000..b0d4b10 --- /dev/null +++ b/internal/explorer/span.go @@ -0,0 +1,52 @@ +package explorer + +import ( + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/disasm" +) + +// Span is a decoded, bounded slice of the executable image: the instructions +// themselves plus the image offsets that bound them. +type Span struct { + Insts []disasm.Inst + // PosLo is the image position of the *first decoded instruction*, which is + // what bounds "is there code above / where does it start". + PosLo int + // PosHi is the image position just past the decoded window. + PosHi int +} + +// Empty reports whether the span decoded to nothing. +func (s Span) Empty() bool { return len(s.Insts) == 0 } + +// Contains reports whether an image position falls inside the decoded span. +func (s Span) Contains(pos int) bool { return pos >= s.PosLo && pos < s.PosHi } + +// DecodeSpanAt decodes a bounded window around addr, using `before` bytes of +// lead-in context, and returns it as a Span. +func (s *DisasmService) DecodeSpanAt(addr uint64, before int) Span { + win, insts := s.DecodeAt(addr, before) + return s.SpanFor(win, insts) +} + +// DecodeSpanWindow decodes an explicit image window as a Span. +func (s *DisasmService) DecodeSpanWindow(win binfile.Window) Span { + return s.SpanFor(win, s.DecodeWindow(win)) +} + +// SpanFor pairs an already-decoded window with its bounds, for callers that +// decoded it themselves (the instruction-text search decodes in parallel chunks). +// +// PosLo is the first instruction's position, not the window's start. The two +// differ when DecodeAt began at a symbol (a section or function jump): the window +// reserves lead bytes that hold no decoded instructions, so anchoring "scroll up" +// on win.Start would jump far before the actual preceding code. +func (s *DisasmService) SpanFor(win binfile.Window, insts []disasm.Inst) Span { + posLo := win.Start + if len(insts) > 0 && s.file != nil { + if p, ok := s.file.ExecImage().PosForAddr(insts[0].Addr); ok { + posLo = p + } + } + return Span{Insts: insts, PosLo: posLo, PosHi: win.End} +} diff --git a/internal/explorer/span_test.go b/internal/explorer/span_test.go new file mode 100644 index 0000000..3f90f8f --- /dev/null +++ b/internal/explorer/span_test.go @@ -0,0 +1,161 @@ +package explorer_test + +import ( + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/explorer" + "github.com/rabarbra/exex/internal/testbin" +) + +func fixtureService(t *testing.T) *binfile.File { + t.Helper() + f, err := binfile.Open(testbin.WriteTinyELF64(t)) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { f.Close() }) + return f +} + +func newService(t *testing.T) *explorer.DisasmService { + t.Helper() + f := fixtureService(t) + d, err := disasm.For(f.Arch()) + if err != nil { + t.Fatalf("disassembler: %v", err) + } + return explorer.NewDisasmService(f, d, 1<<20, 0) +} + +func TestSpanEmptyAndContains(t *testing.T) { + var empty explorer.Span + if !empty.Empty() { + t.Error("zero Span is not empty") + } + s := explorer.Span{Insts: []disasm.Inst{{Addr: 1}}, PosLo: 0x10, PosHi: 0x20} + if s.Empty() { + t.Error("a span with instructions reported empty") + } + for _, tc := range []struct { + pos int + want bool + }{{0x0f, false}, {0x10, true}, {0x1f, true}, {0x20, false}} { + if got := s.Contains(tc.pos); got != tc.want { + t.Errorf("Contains(%#x) = %v, want %v", tc.pos, got, tc.want) + } + } +} + +// TestDecodeSpanAtDecodesRealCode walks the fixture's .text, whose first +// instruction is `mov $0x1,%rax` at 0x401000. +func TestDecodeSpanAtDecodesRealCode(t *testing.T) { + svc := newService(t) + span := svc.DecodeSpanAt(0x401000, 0) + if span.Empty() { + t.Fatal("decoded nothing at the entry point") + } + if got := span.Insts[0].Addr; got != 0x401000 { + t.Errorf("first instruction at %#x, want 0x401000", got) + } + if span.PosHi <= span.PosLo { + t.Errorf("degenerate bounds: PosLo=%d PosHi=%d", span.PosLo, span.PosHi) + } +} + +// TestPosLoIsTheFirstInstructionNotTheWindowStart is the reason Span exists. +// +// Decoding with lead-in context reserves bytes before the target that hold no +// decoded instructions. Anchoring "is there code above" on the raw window start +// would let the view scroll up into that dead lead-in; PosLo pins it to the first +// instruction actually decoded. +func TestPosLoIsTheFirstInstructionNotTheWindowStart(t *testing.T) { + f := fixtureService(t) + d, err := disasm.For(f.Arch()) + if err != nil { + t.Fatalf("disassembler: %v", err) + } + svc := explorer.NewDisasmService(f, d, 1<<20, 0) + + // helper() sits at 0x401020, 0x20 into the executable image. Decoding there + // resyncs from the symbol, so the raw window still starts at 0 while the first + // decoded instruction is at image position 0x20. + const helperAddr = 0x401020 + const lead = 0x20 + + win, insts := svc.DecodeAt(helperAddr, lead) + span := svc.DecodeSpanAt(helperAddr, lead) + if span.Empty() || len(insts) == 0 { + t.Fatal("decoded nothing at helper") + } + if insts[0].Addr != helperAddr { + t.Fatalf("decode began at %#x, want the symbol %#x", insts[0].Addr, helperAddr) + } + firstPos, ok := f.ExecImage().PosForAddr(helperAddr) + if !ok { + t.Fatal("helper is not in the executable image") + } + if span.PosLo != firstPos { + t.Errorf("PosLo = %d, want %d (the first decoded instruction's position)", span.PosLo, firstPos) + } + // The discriminating assertion: the raw window starts *before* PosLo, on lead-in + // bytes that decoded to nothing. Using win.Start would let the view scroll up + // into them. + if span.PosLo <= win.Start { + t.Errorf("PosLo (%d) does not exceed the window start (%d); the test no longer distinguishes them", + span.PosLo, win.Start) + } + if span.PosHi != win.End { + t.Errorf("PosHi = %d, want the window end %d", span.PosHi, win.End) + } + // Nothing decoded sits before PosLo. + for _, in := range span.Insts { + if p, ok := f.ExecImage().PosForAddr(in.Addr); ok && p < span.PosLo { + t.Fatalf("instruction at %#x (pos %d) precedes PosLo %d", in.Addr, p, span.PosLo) + } + } +} + +// TestSpanForUsesAnAlreadyDecodedWindow: the instruction-text search decodes in +// parallel chunks and pairs the result with its bounds afterwards. +func TestSpanForUsesAnAlreadyDecodedWindow(t *testing.T) { + f := fixtureService(t) + d, err := disasm.For(f.Arch()) + if err != nil { + t.Fatalf("disassembler: %v", err) + } + svc := explorer.NewDisasmService(f, d, 1<<20, 0) + + img := f.ExecImage() + win := img.Window(0, img.Len()) + insts := svc.DecodeWindow(win) + if len(insts) == 0 { + t.Fatal("decoded nothing") + } + span := svc.SpanFor(win, insts) + if len(span.Insts) != len(insts) { + t.Errorf("SpanFor dropped instructions: %d vs %d", len(span.Insts), len(insts)) + } + if span.PosHi != win.End { + t.Errorf("PosHi = %d, want the window end %d", span.PosHi, win.End) + } + // DecodeSpanWindow is SpanFor over a fresh decode of the same window. + if got := svc.DecodeSpanWindow(win); got.PosLo != span.PosLo || got.PosHi != span.PosHi { + t.Errorf("DecodeSpanWindow bounds = (%d,%d), want (%d,%d)", got.PosLo, got.PosHi, span.PosLo, span.PosHi) + } +} + +// TestSpanForEmptyDecodeFallsBackToTheWindowStart: with no instructions there is +// no first-instruction position to anchor on. +func TestSpanForEmptyDecodeFallsBackToTheWindowStart(t *testing.T) { + svc := newService(t) + win := binfile.Window{Start: 42, End: 99} + span := svc.SpanFor(win, nil) + if !span.Empty() { + t.Error("a nil decode produced a non-empty span") + } + if span.PosLo != 42 || span.PosHi != 99 { + t.Errorf("bounds = (%d,%d), want the raw window (42,99)", span.PosLo, span.PosHi) + } +} diff --git a/internal/integration/dyldcache_test.go b/internal/integration/dyldcache_test.go new file mode 100644 index 0000000..c855fd1 --- /dev/null +++ b/internal/integration/dyldcache_test.go @@ -0,0 +1,139 @@ +package integration + +import ( + "os" + "strings" + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/dyldcache" + "github.com/rabarbra/exex/internal/explorer" +) + +// TestExtractedDylibParses is the end-to-end check for dyld-cache un-sharing: +// extract real system dylibs, parse each with binfile, and confirm the compact +// rebuilt __LINKEDIT keeps every symbol name at a few-hundred-KB size. +func TestExtractedDylibParses(t *testing.T) { + path, ok := dyldcache.HostCachePath("arm64", func(p string) bool { _, err := os.Stat(p); return err == nil }) + if !ok { + t.Skip("no host dyld shared cache") + } + c, err := dyldcache.Open(path) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + for _, lib := range []string{ + "/usr/lib/system/libsystem_kernel.dylib", + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libc++.1.dylib", + } { + im, ok := c.FindImage(lib) + if !ok { + t.Errorf("%s: not in image table", lib) + continue + } + buf, err := c.ExtractImage(im) + if err != nil { + t.Errorf("%s: extract: %v", lib, err) + continue + } + if len(buf) > 16<<20 { + t.Errorf("%s: extracted %d MB, expected a few hundred KB (compact linkedit failed)", lib, len(buf)>>20) + } + f, err := binfile.OpenBytes(lib, buf) + if err != nil { + t.Errorf("%s: parse: %v", lib, err) + continue + } + named := 0 + for _, s := range f.Symbols { + if s.Name != "" { + named++ + } + } + t.Logf("%s: %d KB, %d sections, %d/%d named symbols, exec=%v", + lib, len(buf)>>10, len(f.Sections), named, len(f.Symbols), f.HasExecCode()) + if len(f.Sections) == 0 || named == 0 || !f.HasExecCode() { + t.Errorf("%s: stitched image incomplete", lib) + } + } +} + +// TestExtractedDylibDisassembles proves the stitched cache dylib is browsable: +// its executable section decodes to instructions like any on-disk Mach-O. +func TestExtractedDylibDisassembles(t *testing.T) { + path, ok := dyldcache.HostCachePath("arm64", func(p string) bool { _, err := os.Stat(p); return err == nil }) + if !ok { + t.Skip("no host dyld shared cache") + } + c, err := dyldcache.Open(path) + if err != nil { + t.Fatal(err) + } + defer c.Close() + im, ok := c.FindImage("/usr/lib/system/libsystem_kernel.dylib") + if !ok { + t.Skip("libsystem_kernel not in cache") + } + buf, err := c.ExtractImage(im) + if err != nil { + t.Fatal(err) + } + f, err := binfile.OpenBytes(im.Path, buf) + if err != nil { + t.Fatal(err) + } + dis, err := disasm.For(f.Arch()) + if err != nil { + t.Skipf("no disassembler for %v", f.Arch()) + } + svc := explorer.NewDisasmService(f, dis, 1<<20, 0) + addr := explorer.DefaultExecAddr(f, "lowest") + if addr == 0 { + t.Fatal("no executable entry address") + } + win, insts := svc.DecodeAt(addr, 0) + if len(insts) == 0 { + t.Fatalf("decoded no instructions at %#x (window %#x..%#x)", addr, win.Start, win.End) + } + t.Logf("decoded %d instructions from %#x", len(insts), addr) +} + +// TestSyscallsFullThroughCache is the end-to-end check for roadmap #33's second +// half: a macOS binary's real syscall surface comes from libsystem_kernel, which +// lives only in the dyld shared cache and is reached transitively through the +// libSystem.B re-export umbrella. The full scan must extract it from the cache +// and report its svc sites. +func TestSyscallsFullThroughCache(t *testing.T) { + if _, err := os.Stat("/bin/ls"); err != nil { + t.Skip("no /bin/ls") + } + if _, ok := dyldcache.HostCachePath("arm64", func(p string) bool { _, e := os.Stat(p); return e == nil }); !ok { + if _, ok := dyldcache.HostCachePath("x86-64", func(p string) bool { _, e := os.Stat(p); return e == nil }); !ok { + t.Skip("no host dyld shared cache") + } + } + f, err := binfile.Open("/bin/ls") + if err != nil { + t.Fatal(err) + } + defer f.Close() + sites, objs, _ := dump.CollectSyscallsFull(f) + if objs < 5 { + t.Fatalf("only %d objects scanned — dependency walk didn't reach the cache", objs) + } + fromKernel := 0 + for _, s := range sites { + if strings.Contains(s.Origin, "libsystem_kernel") { + fromKernel++ + } + } + t.Logf("%d objects scanned, %d sites, %d from libsystem_kernel", objs, len(sites), fromKernel) + if fromKernel < 100 { + t.Fatalf("expected 100+ syscall sites from libsystem_kernel via the cache, got %d", fromKernel) + } +} diff --git a/internal/sourcefiles/files.go b/internal/sourcefiles/files.go index b59d8e0..8cf8f08 100644 --- a/internal/sourcefiles/files.go +++ b/internal/sourcefiles/files.go @@ -93,3 +93,28 @@ func Grep(files []string, linesFor func(string) []string, query string, limit in } return out } + +// GrepStream is Grep for a streaming line source. scan calls yield in line +// order and stops when yield returns false, so callers need not retain every +// source file merely to search it. +func GrepStream(files []string, scan func(string, func(string) bool), query string, limit int) []Match { + q := strings.ToLower(query) + if q == "" || limit <= 0 { + return nil + } + var out []Match + for _, file := range files { + lineNo := 0 + scan(file, func(line string) bool { + lineNo++ + if strings.Contains(strings.ToLower(line), q) { + out = append(out, Match{File: file, Line: lineNo}) + } + return len(out) < limit + }) + if len(out) >= limit { + return out + } + } + return out +} diff --git a/internal/sourcefiles/files_test.go b/internal/sourcefiles/files_test.go index 6b38ba1..95cc299 100644 --- a/internal/sourcefiles/files_test.go +++ b/internal/sourcefiles/files_test.go @@ -82,6 +82,24 @@ func TestGrep(t *testing.T) { } } +func TestGrepStream(t *testing.T) { + files := []string{"a.go", "b.go"} + contents := map[string][]string{ + "a.go": {"one", "needle"}, + "b.go": {"Needle again", "needle third"}, + } + matches := GrepStream(files, func(file string, yield func(string) bool) { + for _, line := range contents[file] { + if !yield(line) { + return + } + } + }, "needle", 2) + if len(matches) != 2 || matches[0] != (Match{File: "a.go", Line: 2}) || matches[1] != (Match{File: "b.go", Line: 1}) { + t.Fatalf("matches = %#v", matches) + } +} + func containsSame(got, want []string) bool { if len(got) != len(want) { return false diff --git a/internal/syntax/chromastyle.go b/internal/syntax/chromastyle.go new file mode 100644 index 0000000..98f45db --- /dev/null +++ b/internal/syntax/chromastyle.go @@ -0,0 +1,34 @@ +//go:build !lite + +package syntax + +import ( + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" +) + +// StyleEntryToLipgloss converts the subset of Chroma style attributes exex +// renders. Tokens a style leaves uncoloured take fallbackFG (see +// theme.ForegroundFor), so a style that only highlights keywords still draws its +// body text in the right colour instead of the terminal default. +// +// Both the source pane (this package) and the disassembly pane (internal/ui) +// need it, and each had its own byte-identical copy. +func StyleEntryToLipgloss(e chroma.StyleEntry, fallbackFG string) lipgloss.Style { + s := lipgloss.NewStyle() + if e.Colour.IsSet() { + s = s.Foreground(lipgloss.Color(e.Colour.String())) + } else if fallbackFG != "" { + s = s.Foreground(lipgloss.Color(fallbackFG)) + } + if e.Bold == chroma.Yes { + s = s.Bold(true) + } + if e.Italic == chroma.Yes { + s = s.Italic(true) + } + if e.Underline == chroma.Yes { + s = s.Underline(true) + } + return s +} diff --git a/internal/syntax/coverage_test.go b/internal/syntax/coverage_test.go new file mode 100644 index 0000000..d7682f2 --- /dev/null +++ b/internal/syntax/coverage_test.go @@ -0,0 +1,85 @@ +//go:build !lite + +package syntax + +import ( + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/chromalexers" +) + +// langSample maps every source language exex can identify from DWARF +// (binfile.DwarfLanguageNames) to a representative source filename. A non-empty +// value must resolve to a curated Chroma lexer; the empty string marks a language +// we knowingly leave to the minimal highlighter (dead, GPU-only, or C/C++ +// dialects whose real source files carry a covered extension). +// +// TestDwarfLanguagesHaveCoverage keeps this in lockstep with dwarfLangNames: add +// a DWARF language there and this test fails until you record a decision here, so +// the language table and the highlighter set cannot silently drift apart. +var langSample = map[string]string{ + "C": "main.c", + "C++": "main.cpp", + "Ada": "main.adb", + "COBOL": "main.cob", + "Fortran": "main.f90", + "Pascal": "main.pas", + "Modula-2": "main.mod", + "Java": "Main.java", + "Objective-C": "main.m", + "Objective-C++": "main.mm", + "D": "main.d", + "Python": "main.py", + "OpenCL": "kernel.cl", + "Go": "main.go", + "Haskell": "main.hs", + "OCaml": "main.ml", + "Rust": "main.rs", + "Swift": "main.swift", + "Julia": "main.jl", + "Dylan": "main.dylan", + "Kotlin": "main.kt", + "Zig": "main.zig", + "Crystal": "main.cr", + "Assembly": "boot.s", + "C#": "main.cs", + "Mojo": "main.mojo", + "GLSL": "shader.frag", + "GLSL ES": "shader.frag", + "HLSL": "shader.hlsl", + "Metal": "shader.metal", + "Ruby": "main.rb", + "Delphi": "main.pas", + "OpenCL C++": "kernel.cpp", // C++ dialect; real sources are .cpp + "C++ for OpenCL": "kernel.cpp", // C++ dialect; real sources are .cpp + "SYCL": "main.cpp", // C++ dialect; real sources are .cpp + + // Knowingly minimal-fallback: no lexer, and no realistic ELF/Mach-O DWARF + // source we could route to one. Dead/niche (Modula-3, BLISS, Move, Hylo, + // RenderScript, UPC) or GPU dialects handled via their .cpp sources (HIP). + "UPC": "", + "Modula-3": "", + "RenderScript": "", + "BLISS": "", + "HIP": "", + "Move": "", + "Hylo": "", +} + +func TestDwarfLanguagesHaveCoverage(t *testing.T) { + for _, name := range binfile.DwarfLanguageNames() { + sample, recorded := langSample[name] + if !recorded { + t.Errorf("DWARF language %q has no coverage decision: add it to langSample "+ + "(a sample filename if a lexer should highlight it, or \"\" for minimal fallback)", name) + continue + } + if sample == "" { + continue // explicitly minimal-fallback + } + if l := chromalexers.Match(sample); l == nil { + t.Errorf("DWARF language %q: sample %q resolves to no curated lexer", name, sample) + } + } +} diff --git a/internal/syntax/highlight.go b/internal/syntax/highlight.go index fd08bdd..489ab86 100644 --- a/internal/syntax/highlight.go +++ b/internal/syntax/highlight.go @@ -1,28 +1,55 @@ // Package syntax highlights source files for display in the TUI source pane. // -// The tokeniser (Chroma) is compiled in only for the default build. The `lite` -// build tag swaps in a small built-in highlighter (highlight_lite.go), dropping -// Chroma and its embedded lexer/style data while keeping basic source colours. +// A curated Chroma tokeniser is compiled in only for the default build. The +// `lite` build tag swaps in a small built-in highlighter (highlight_lite.go), +// dropping Chroma while keeping basic source colours. package syntax -import "sync" +import ( + "container/list" + "sync" + "unsafe" -const defaultTheme = "nord" + "github.com/rabarbra/exex/internal/theme" +) -// Highlighter tokenises source files once and caches their per-line ANSI output. +// defaultTheme is the style used when the caller names none. +const defaultTheme = theme.DefaultName + +// Highlighter tokenises source files and caches recent per-line ANSI output. type Highlighter struct { - theme string - mu sync.RWMutex - cache map[string][]string + theme string + mu sync.Mutex + cache map[string]*list.Element + lru list.List + cacheBytes int + budget int + maxEntries int +} + +type highlightCacheEntry struct { + filename string + lines []string + weight int } +const ( + defaultHighlightCacheBudget = 64 << 20 + defaultHighlightCacheEntries = 32 +) + // NewHighlighter creates a cached source highlighter. An empty theme selects the // project default. func NewHighlighter(theme string) *Highlighter { if theme == "" { theme = defaultTheme } - return &Highlighter{theme: theme, cache: map[string][]string{}} + return &Highlighter{ + theme: theme, + cache: map[string]*list.Element{}, + budget: defaultHighlightCacheBudget, + maxEntries: defaultHighlightCacheEntries, + } } // Highlight returns ANSI-styled source lines for filename, using a per-filename @@ -32,15 +59,43 @@ func (h *Highlighter) Highlight(filename string, src []string) []string { if h == nil { return HighlightLines(filename, src, defaultTheme) } - h.mu.RLock() - v, ok := h.cache[filename] - h.mu.RUnlock() - if ok { - return v + h.mu.Lock() + if h.cache == nil { + h.cache = map[string]*list.Element{} + } + if h.budget <= 0 { + h.budget = defaultHighlightCacheBudget } + if h.maxEntries <= 0 { + h.maxEntries = defaultHighlightCacheEntries + } + if elem := h.cache[filename]; elem != nil { + h.lru.MoveToFront(elem) + lines := elem.Value.(*highlightCacheEntry).lines + h.mu.Unlock() + return lines + } + h.mu.Unlock() hl := HighlightLines(filename, src, h.theme) + weight := cap(hl) * int(unsafe.Sizeof("")) + for _, line := range hl { + weight += len(line) + } h.mu.Lock() - h.cache[filename] = hl - h.mu.Unlock() + defer h.mu.Unlock() + if elem := h.cache[filename]; elem != nil { + h.lru.MoveToFront(elem) + return elem.Value.(*highlightCacheEntry).lines + } + entry := &highlightCacheEntry{filename: filename, lines: hl, weight: weight} + h.cache[filename] = h.lru.PushFront(entry) + h.cacheBytes += weight + for h.lru.Len() > 1 && (h.cacheBytes > h.budget || h.lru.Len() > h.maxEntries) { + oldest := h.lru.Back() + old := oldest.Value.(*highlightCacheEntry) + delete(h.cache, old.filename) + h.lru.Remove(oldest) + h.cacheBytes -= old.weight + } return hl } diff --git a/internal/syntax/highlight_chroma.go b/internal/syntax/highlight_chroma.go index 865eb46..6b70d6c 100644 --- a/internal/syntax/highlight_chroma.go +++ b/internal/syntax/highlight_chroma.go @@ -7,103 +7,104 @@ import ( "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" + "github.com/rabarbra/exex/internal/chromalexers" + "github.com/rabarbra/exex/internal/chromastyles" "github.com/rabarbra/exex/internal/theme" ) // HighlightLines returns ANSI-styled source lines without using a cache. It uses // the minimal highlighter when Chroma cannot identify or tokenise the file. func HighlightLines(filename string, src []string, themeName string) []string { - lexer := lexerFor(filename, src) + if strings.TrimSpace(themeName) == "" { + themeName = defaultTheme + } + // Style lookup is cheap; do it before the (joined-source) lexer work so a + // non-bundled theme skips tokenising entirely. + st, ok := chromastyles.Lookup(themeName) + if !ok || st == nil { + return minimalHighlight(filename, src, themeName) + } + + joined := strings.Join(src, "\n") + lexer := lexerFor(filename, joined) if lexer == nil { // Unknown file type: fall back to the tiny built-in highlighter rather // than rendering plain text. return minimalHighlight(filename, src, themeName) } lexer = chroma.Coalesce(lexer) + fallbackFG := theme.ForegroundFor(themeName) - st := styles.Get(themeName) - if st == nil { - st = styles.Fallback + if lines, ok := chromaHighlight(lexer, joined, st, fallbackFG, len(src)); ok { + return lines } - fallbackFG := chromaFallbackForeground(themeName) + return minimalHighlight(filename, src, themeName) +} - it, err := lexer.Tokenise(nil, strings.Join(src, "\n")) +// chromaHighlight tokenises joined and renders one ANSI-styled string per line. +// ok is false (caller falls back to the minimal highlighter) on a tokenise error +// or a panic. A lexer that delegates via panics if X is not in +// the curated registry, so the recover keeps a single unsupported embed (e.g. a +// stray language reference) from crashing the whole app on one file. +func chromaHighlight(lexer chroma.Lexer, joined string, st *chroma.Style, fallbackFG string, nSrc int) (lines []string, ok bool) { + defer func() { + if recover() != nil { + lines, ok = nil, false + } + }() + + it, err := lexer.Tokenise(nil, joined) if err != nil { - return minimalHighlight(filename, src, themeName) + return nil, false } // Memoise the lipgloss style per token type: a source file has thousands of // tokens but only a handful of distinct types. styleFor := map[chroma.TokenType]lipgloss.Style{} - lines := make([]string, 0, len(src)) + lines = make([]string, 0, nSrc) var cur strings.Builder for _, tok := range it.Tokens() { ls, ok := styleFor[tok.Type] if !ok { - ls = chromaToLipgloss(st.Get(tok.Type), fallbackFG) + ls = StyleEntryToLipgloss(st.Get(tok.Type), fallbackFG) styleFor[tok.Type] = ls } - parts := strings.Split(tok.Value, "\n") - for i, p := range parts { - if i > 0 { - lines = append(lines, cur.String()) - cur.Reset() + // Most tokens have no newline: render straight into the current line + // without the per-token strings.Split allocation. + val := tok.Value + for { + nl := strings.IndexByte(val, '\n') + if nl < 0 { + break } - if p != "" { + if p := val[:nl]; p != "" { cur.WriteString(ls.Render(p)) } + lines = append(lines, cur.String()) + cur.Reset() + val = val[nl+1:] + } + if val != "" { + cur.WriteString(ls.Render(val)) } } lines = append(lines, cur.String()) - return lines + return lines, true } // lexerFor picks the Chroma lexer for a file. Assembly sources (.s/.S) are -// special-cased to GAS: three lexers (ArmAsm, GAS, R) all register that -// extension at the same priority, so lexers.Match can pick R and highlight -// assembly as the R language. GAS (GNU assembler) is what these files actually -// are. Everything else uses the normal filename match, then content analysis. -func lexerFor(filename string, src []string) chroma.Lexer { +// special-cased to GAS because ArmAsm and GAS both register that extension, and +// GAS (GNU assembler) is the usual format for those files. Everything else uses +// the normal curated filename match, then content analysis. +func lexerFor(filename, src string) chroma.Lexer { if lowerExt(filename) == ".s" { - if l := lexers.Get("gas"); l != nil { + if l := chromalexers.Get("gas"); l != nil { return l } } - if l := lexers.Match(filename); l != nil { + if l := chromalexers.Match(filename); l != nil { return l } - return lexers.Analyse(strings.Join(src, "\n")) -} - -// chromaToLipgloss converts the subset of Chroma style attributes used here. -func chromaToLipgloss(e chroma.StyleEntry, fallbackFG string) lipgloss.Style { - s := lipgloss.NewStyle() - if e.Colour.IsSet() { - s = s.Foreground(lipgloss.Color(e.Colour.String())) - } else if fallbackFG != "" { - s = s.Foreground(lipgloss.Color(fallbackFG)) - } - if e.Bold == chroma.Yes { - s = s.Bold(true) - } - if e.Italic == chroma.Yes { - s = s.Italic(true) - } - if e.Underline == chroma.Yes { - s = s.Underline(true) - } - return s -} - -func chromaFallbackForeground(name string) string { - if p, ok := theme.PaletteFor(strings.TrimSpace(name)); ok { - return p.Foreground - } - if p, ok := theme.PaletteFor(defaultTheme); ok { - return p.Foreground - } - return "" + return chromalexers.Analyse(src) } diff --git a/internal/syntax/highlight_test.go b/internal/syntax/highlight_test.go index b7d5fc0..ed71d2e 100644 --- a/internal/syntax/highlight_test.go +++ b/internal/syntax/highlight_test.go @@ -3,11 +3,14 @@ package syntax import ( + "reflect" "strings" "testing" "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" + + "github.com/rabarbra/exex/internal/chromastyles" ) func TestHighlightLines(t *testing.T) { @@ -40,7 +43,7 @@ func TestHighlightLines(t *testing.T) { // register that extension at the same priority, so a plain lexers.Match can pick // R and highlight assembly as the R language. lexerFor must force GAS. func TestAssemblySourceUsesGAS(t *testing.T) { - src := []string{" .globl main", "main:", " ret"} + src := " .globl main\nmain:\n ret" for _, name := range []string{"foo.s", "foo.S", "crt0.S"} { l := lexerFor(name, src) if l == nil { @@ -52,6 +55,48 @@ func TestAssemblySourceUsesGAS(t *testing.T) { } } +func TestGoSourceUsesCuratedLexer(t *testing.T) { + l := lexerFor("main.go", "package main\nfunc main() {}") + if l == nil { + t.Fatal("main.go: no lexer") + } + if got := l.Config().Name; got != "Go" { + t.Fatalf("main.go: lexer = %q, want Go", got) + } +} + +func TestUnsupportedLanguageFallsBackToMinimal(t *testing.T) { + src := []string{`defmodule Demo do`} + if l := lexerFor("main.exs", strings.Join(src, "\n")); l != nil { + t.Fatalf("main.exs: lexer = %q, want nil", l.Config().Name) + } + hl := HighlightLines("main.exs", src, defaultTheme) + if len(hl) != len(src) { + t.Fatalf("highlighted line count = %d, want %d", len(hl), len(src)) + } + if got := stripANSI(hl[0]); got != src[0] { + t.Fatalf("plain text = %q, want %q", got, src[0]) + } + if !strings.Contains(hl[0], "\x1b[") { + t.Fatal("expected minimal highlighter ANSI colour codes") + } +} + +func TestUnsupportedChromaStyleFallsBackToMinimal(t *testing.T) { + // A name Chroma will never register, so curating more styles into + // internal/chromasubset/styles.txt can't quietly invalidate this test. + const style = "definitely-not-a-style" + if _, ok := chromastyles.Lookup(style); ok { + t.Fatalf("%s is bundled; pick an unbundled style for this test", style) + } + src := []string{"package main", "func main() {}"} + got := HighlightLines("main.go", src, style) + want := minimalHighlight("main.go", src, style) + if !reflect.DeepEqual(got, want) { + t.Fatalf("unsupported style did not use minimal fallback\ngot: %q\nwant: %q", got, want) + } +} + func TestHighlightUnknownExtension(t *testing.T) { hl := HighlightLines("data.unknownext", []string{"\x00\x01\x02"}, defaultTheme) for _, line := range hl { @@ -85,14 +130,44 @@ func TestHighlighterCachesByFilename(t *testing.T) { } } +func TestHighlighterEvictsLeastRecentlyUsed(t *testing.T) { + h := NewHighlighter("") + h.maxEntries = 2 + h.budget = 1 << 20 + h.Highlight("a.go", []string{"package a"}) + h.Highlight("b.go", []string{"package b"}) + h.Highlight("a.go", []string{"package changed"}) // refresh a.go + h.Highlight("c.go", []string{"package c"}) + if h.cache["a.go"] == nil || h.cache["c.go"] == nil { + t.Fatal("recent highlights were evicted") + } + if h.cache["b.go"] != nil { + t.Fatal("least recently used highlight remained cached") + } +} + +// TestChromaDefaultTokenUsesThemeForeground covers the converter shared by the +// source pane and the disassembly pane (internal/ui/disasm_syntax.go). func TestChromaDefaultTokenUsesThemeForeground(t *testing.T) { - got := chromaToLipgloss(chroma.StyleEntry{}, "#586e75").Render("x") + got := StyleEntryToLipgloss(chroma.StyleEntry{}, "#586e75").Render("x") want := lipgloss.NewStyle().Foreground(lipgloss.Color("#586e75")).Render("x") if got != want { t.Fatalf("default token style = %q, want %q", got, want) } } +func TestChromaStyledTokenAttributes(t *testing.T) { + e := chroma.StyleEntry{Bold: chroma.Yes, Italic: chroma.Yes, Underline: chroma.Yes} + got := StyleEntryToLipgloss(e, "#586e75").Render("x") + want := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#586e75")). + Bold(true).Italic(true).Underline(true). + Render("x") + if got != want { + t.Fatalf("styled token = %q, want %q", got, want) + } +} + func stripANSI(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { diff --git a/internal/syntax/minimal.go b/internal/syntax/minimal.go index fbc612f..c50d274 100644 --- a/internal/syntax/minimal.go +++ b/internal/syntax/minimal.go @@ -25,6 +25,8 @@ var ( mhFunctionName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) mhType = lipgloss.NewStyle().Foreground(lipgloss.Color("176")) mhLiteral = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) + mhOperator = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + mhPreproc = lipgloss.NewStyle().Foreground(lipgloss.Color("173")) ) type minimalPalette struct { @@ -38,6 +40,8 @@ type minimalPalette struct { functionName lipgloss.Style typ lipgloss.Style literal lipgloss.Style + operator lipgloss.Style // operators / punctuation + preproc lipgloss.Style // C preprocessor directives (#include, #define, …) } var defaultMinimalPalette = minimalPalette{ @@ -51,6 +55,8 @@ var defaultMinimalPalette = minimalPalette{ functionName: mhFunctionName, typ: mhType, literal: mhLiteral, + operator: mhOperator, + preproc: mhPreproc, } var ( @@ -65,6 +71,8 @@ var ( functionName: lipgloss.NewStyle().Foreground(lipgloss.Color("#8fbcbb")), typ: lipgloss.NewStyle().Foreground(lipgloss.Color("#ebcb8b")), literal: lipgloss.NewStyle().Foreground(lipgloss.Color("#d08770")), + operator: lipgloss.NewStyle().Foreground(lipgloss.Color("#81a1c1")), + preproc: lipgloss.NewStyle().Foreground(lipgloss.Color("#5e81ac")), } solarizedDarkMinimalPalette = solarizedMinimalPalette("#93a1a1", "#586e75") solarizedLightMinimalPalette = solarizedMinimalPalette("#586e75", "#93a1a1") @@ -93,6 +101,12 @@ func minimalPaletteForTheme(name string) minimalPalette { // styles. func minimalPaletteFromChroma(p theme.Palette) minimalPalette { fg := func(hex string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) } + or := func(a, b string) string { + if a != "" { + return a + } + return b + } return minimalPalette{ text: fg(p.Foreground), comment: fg(p.Comment), @@ -104,6 +118,8 @@ func minimalPaletteFromChroma(p theme.Palette) minimalPalette { functionName: fg(p.Function), typ: fg(p.Type), literal: fg(p.Number), + operator: fg(or(p.Operator, p.Foreground)), + preproc: fg(or(p.Comment, p.Keyword)), } } @@ -119,6 +135,8 @@ func solarizedMinimalPalette(text, comment string) minimalPalette { functionName: lipgloss.NewStyle().Foreground(lipgloss.Color("#268bd2")), typ: lipgloss.NewStyle().Foreground(lipgloss.Color("#6c71c5")), literal: lipgloss.NewStyle().Foreground(lipgloss.Color("#cb4b16")), + operator: lipgloss.NewStyle().Foreground(lipgloss.Color(comment)), + preproc: lipgloss.NewStyle().Foreground(lipgloss.Color("#cb4b16")), } } @@ -207,7 +225,30 @@ func mhLine(line string, cs commentSyntax, inBlock bool, pal minimalPalette) (st var b strings.Builder n := len(line) wantFuncName := false - for i := 0; i < n; { + i := 0 + // Preprocessor directive: a line whose first non-blank character is '#' in a + // C-style (block-comment) language — #include, #define, #pragma, #ifdef, … — + // colours the whole "#directive" token. Hash-comment languages handle '#' as a + // comment instead (see commentFor), so this only fires for C-family sources. + if !inBlock && cs.block { + ws := i + for ws < n && (line[ws] == ' ' || line[ws] == '\t') { + ws++ + } + if ws < n && line[ws] == '#' { + j := ws + 1 + for j < n && (line[j] == ' ' || line[j] == '\t') { + j++ + } + for j < n && isIdentChar(line[j]) { + j++ + } + b.WriteString(line[i:ws]) // leading whitespace, unstyled + b.WriteString(pal.preproc.Render(line[ws:j])) + i = j + } + } + for i < n { if inBlock { if j := strings.Index(line[i:], "*/"); j >= 0 { b.WriteString(pal.comment.Render(line[i : i+j+2])) @@ -260,6 +301,13 @@ func mhLine(line string, cs commentSyntax, inBlock bool, pal minimalPalette) (st wantFuncName = false } i = j + case isOperatorByte(c): + j := i + 1 + for j < n && isOperatorByte(line[j]) { + j++ + } + b.WriteString(pal.operator.Render(line[i:j])) + i = j default: b.WriteString(pal.text.Render(line[i : i+1])) i++ @@ -268,6 +316,18 @@ func mhLine(line string, cs commentSyntax, inBlock bool, pal minimalPalette) (st return b.String(), inBlock } +// isOperatorByte reports whether c is an operator or punctuation byte. Runs of +// these share the operator colour, so multi-byte operators (==, &&, ::) read as +// one unit. '/' reaches here only when the comment cases above did not consume it. +func isOperatorByte(c byte) bool { + switch c { + case '+', '-', '*', '/', '%', '=', '<', '>', '!', '&', '|', '^', '~', + '?', ':', ';', ',', '.', '(', ')', '[', ']', '{', '}', '@': + return true + } + return false +} + func mhStyleForCategory(cat mhKeywordCategory, pal minimalPalette) lipgloss.Style { switch cat { case mhCatControl: diff --git a/internal/syntax/minimal_test.go b/internal/syntax/minimal_test.go index d6deaf4..e651338 100644 --- a/internal/syntax/minimal_test.go +++ b/internal/syntax/minimal_test.go @@ -84,6 +84,42 @@ func TestMinimalHighlightFollowsThemePalette(t *testing.T) { } } +func TestMinimalHighlightOperatorsAndPreproc(t *testing.T) { + src := []string{ + "#include ", + " # define N 3", + "a += b == c;", + } + out := minimalHighlight("x.c", src, "") + for i, line := range src { + if plain := ansi.Strip(out[i]); plain != line { + t.Fatalf("line %d plain text = %q, want %q", i, plain, line) + } + } + // The directive token (leading whitespace excluded) is preproc-styled. + if !strings.Contains(out[0], mhPreproc.Render("#include")) { + t.Errorf("#include not preproc-styled: %q", out[0]) + } + if !strings.Contains(out[1], mhPreproc.Render("# define")) { + t.Errorf("spaced directive not preproc-styled: %q", out[1]) + } + // A run of operator bytes shares the operator colour ("+=", "=="). + if !strings.Contains(out[2], mhOperator.Render("+=")) { + t.Errorf("operator run += not styled: %q", out[2]) + } + if !strings.Contains(out[2], mhOperator.Render("==")) { + t.Errorf("operator run == not styled: %q", out[2]) + } +} + +// A '#' mid-line in a hash-comment language must stay a comment, not a directive. +func TestMinimalHashLanguageNotTreatedAsPreproc(t *testing.T) { + out := minimalHighlight("x.py", []string{"#!/usr/bin/env python"}, "")[0] + if !strings.Contains(out, mhComment.Render("#!/usr/bin/env python")) { + t.Errorf("python shebang not treated as comment: %q", out) + } +} + func TestMinimalPlainTextUsesThemeForeground(t *testing.T) { got := minimalHighlight("plain.txt", []string{"plain + text"}, "solarized-light")[0] want := lipgloss.NewStyle().Foreground(lipgloss.Color("#586e75")).Render("plain") diff --git a/internal/testbin/elf.go b/internal/testbin/elf.go new file mode 100644 index 0000000..5d9d6e6 --- /dev/null +++ b/internal/testbin/elf.go @@ -0,0 +1,255 @@ +// Package testbin hand-builds tiny, byte-for-byte deterministic binaries for +// tests. +// +// It exists because the alternatives aren't reproducible. Compiling a fixture +// (internal/binfile's buildSample) depends on the host toolchain, and opening a +// system binary (/bin/ls) depends on the host OS and libc. Neither can back a +// golden-frame test, whose whole premise is that the same input renders the same +// bytes on every machine. +// +// Nothing outside tests imports this package, so it does not link into exex. +package testbin + +import "encoding/binary" + +// ELF64 constants, spelled out so the layout below reads as the spec does. +const ( + etExec = 2 // e_type: executable + emX86_64 = 62 // e_machine + ptLoad = 1 // p_type + shtProgbits = 1 // sh_type + shtSymtab = 2 + shtStrtab = 3 + shfWrite = 0x1 // sh_flags + shfAlloc = 0x2 + shfExecinstr = 0x4 + + stbGlobal = 1 // symbol binding + sttFunc = 2 // symbol type + sttObject = 1 + + ehSize = 64 // e_ehsize + phEntSz = 56 // e_phentsize + shEntSz = 64 // e_shentsize + symEntSz = 24 + + baseAddr = 0x400000 + textOff = 0x1000 + rodataOff = 0x2000 + dataOff = 0x3000 + tabsOff = 0x4000 +) + +// textCode is a hand-assembled x86-64 `_start` and `helper`, chosen so the +// disassembly view has one of everything it colours differently: immediate +// moves, a call, a syscall, a ret, and a padded function boundary. +// +// _start: ; 0x401000 +// 48 c7 c0 01 00 00 00 mov rax, 1 +// 48 c7 c7 01 00 00 00 mov rdi, 1 +// e8 0d 00 00 00 call helper +// 0f 05 syscall +// c3 ret +// 90 90 ... (pad to 0x20) +// helper: ; 0x401020 +// 55 push rbp +// 48 89 e5 mov rbp, rsp +// b8 2a 00 00 00 mov eax, 42 +// 5d pop rbp +// c3 ret +var textCode = func() []byte { + code := []byte{ + 0x48, 0xc7, 0xc0, 0x01, 0x00, 0x00, 0x00, + 0x48, 0xc7, 0xc7, 0x01, 0x00, 0x00, 0x00, + 0xe8, 0x0d, 0x00, 0x00, 0x00, // rel32 = helperStart(32) - nextInsn(19) + 0x0f, 0x05, + 0xc3, + } + for len(code) < 32 { // pad _start out to helper's 16-byte-aligned start + code = append(code, 0x90) + } + return append(code, + 0x55, + 0x48, 0x89, 0xe5, + 0xb8, 0x2a, 0x00, 0x00, 0x00, + 0x5d, + 0xc3, + ) +}() + +const helperOff = 32 // offset of `helper` within textCode + +// rodata holds NUL-terminated strings long enough to survive the Strings view's +// minimum-length filter, including a path so the path-colouring branch runs. +var rodata = []byte("hello world\x00exex golden fixture\x00/tmp/sample.c\x00") + +// data holds a little-endian pointer to rodata's first string, so the hex view's +// "follow pointer at cursor" path has something real to resolve. +var data = func() []byte { + b := make([]byte, 16) + binary.LittleEndian.PutUint64(b, baseAddr+rodataOff) + copy(b[8:], []byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33}) + return b +}() + +type section struct { + name string + typ uint32 + flags uint64 + addr, off, size uint64 + link, info uint32 + align, entsize uint64 +} + +type symbol struct { + name string + info uint8 + shndx uint16 + value, size uint64 +} + +// TinyELF64 returns a complete, deterministic ET_EXEC ELF64 image for x86-64. +// The same bytes every call, on every platform. +func TinyELF64() []byte { + syms := []symbol{ + {}, // index 0 is the reserved null symbol + {name: "_start", info: stbGlobal<<4 | sttFunc, shndx: 1, value: baseAddr + textOff, size: helperOff}, + {name: "helper", info: stbGlobal<<4 | sttFunc, shndx: 1, value: baseAddr + textOff + helperOff, size: uint64(len(textCode) - helperOff)}, + {name: "msg", info: stbGlobal<<4 | sttObject, shndx: 2, value: baseAddr + rodataOff, size: 12}, + } + strtab, symNameOff := buildStrtab(syms) + symtab := buildSymtab(syms, symNameOff) + + symtabOff := uint64(tabsOff) + strtabOff := symtabOff + uint64(len(symtab)) + shstrOff := strtabOff + uint64(len(strtab)) + + names := []string{"", ".text", ".rodata", ".data", ".symtab", ".strtab", ".shstrtab"} + shstrtab, shNameOff := buildShstrtab(names) + + secs := []section{ + {name: ""}, + {name: ".text", typ: shtProgbits, flags: shfAlloc | shfExecinstr, addr: baseAddr + textOff, off: textOff, size: uint64(len(textCode)), align: 16}, + {name: ".rodata", typ: shtProgbits, flags: shfAlloc, addr: baseAddr + rodataOff, off: rodataOff, size: uint64(len(rodata)), align: 1}, + {name: ".data", typ: shtProgbits, flags: shfAlloc | shfWrite, addr: baseAddr + dataOff, off: dataOff, size: uint64(len(data)), align: 8}, + // sh_link is the string table; sh_info is the index of the first global + // symbol, which is 1 here because there are no locals after the null entry. + {name: ".symtab", typ: shtSymtab, off: symtabOff, size: uint64(len(symtab)), link: 5, info: 1, align: 8, entsize: symEntSz}, + {name: ".strtab", typ: shtStrtab, off: strtabOff, size: uint64(len(strtab)), align: 1}, + {name: ".shstrtab", typ: shtStrtab, off: shstrOff, size: uint64(len(shstrtab)), align: 1}, + } + + shoff := align8(shstrOff + uint64(len(shstrtab))) + total := shoff + uint64(len(secs)*shEntSz) + + buf := make([]byte, total) + writeELFHeader(buf, shoff, len(secs)) + writeProgHeader(buf, total) + copy(buf[textOff:], textCode) + copy(buf[rodataOff:], rodata) + copy(buf[dataOff:], data) + copy(buf[symtabOff:], symtab) + copy(buf[strtabOff:], strtab) + copy(buf[shstrOff:], shstrtab) + for i, s := range secs { + writeSectionHeader(buf[shoff+uint64(i*shEntSz):], s, shNameOff[s.name]) + } + return buf +} + +func writeELFHeader(b []byte, shoff uint64, shnum int) { + copy(b, []byte{0x7f, 'E', 'L', 'F'}) + b[4] = 2 // ELFCLASS64 + b[5] = 1 // ELFDATA2LSB + b[6] = 1 // EV_CURRENT + le := binary.LittleEndian + le.PutUint16(b[16:], etExec) + le.PutUint16(b[18:], emX86_64) + le.PutUint32(b[20:], 1) // e_version + le.PutUint64(b[24:], baseAddr+textOff) + le.PutUint64(b[32:], ehSize) // e_phoff + le.PutUint64(b[40:], shoff) + le.PutUint32(b[48:], 0) // e_flags + le.PutUint16(b[52:], ehSize) + le.PutUint16(b[54:], phEntSz) + le.PutUint16(b[56:], 1) // e_phnum + le.PutUint16(b[58:], shEntSz) + le.PutUint16(b[60:], uint16(shnum)) + le.PutUint16(b[62:], uint16(shnum-1)) // e_shstrndx: .shstrtab is last +} + +// writeProgHeader emits one PT_LOAD covering the whole file, which is all the +// loader-shaped metadata exex's ELF reader looks at here. +func writeProgHeader(b []byte, total uint64) { + p := b[ehSize:] + le := binary.LittleEndian + le.PutUint32(p[0:], ptLoad) + le.PutUint32(p[4:], 5) // PF_R|PF_X + le.PutUint64(p[8:], 0) // p_offset + le.PutUint64(p[16:], baseAddr) + le.PutUint64(p[24:], baseAddr) // p_paddr + le.PutUint64(p[32:], total) // p_filesz + le.PutUint64(p[40:], total) // p_memsz + le.PutUint64(p[48:], 0x1000) // p_align +} + +func writeSectionHeader(b []byte, s section, nameOff uint32) { + le := binary.LittleEndian + le.PutUint32(b[0:], nameOff) + le.PutUint32(b[4:], s.typ) + le.PutUint64(b[8:], s.flags) + le.PutUint64(b[16:], s.addr) + le.PutUint64(b[24:], s.off) + le.PutUint64(b[32:], s.size) + le.PutUint32(b[40:], s.link) + le.PutUint32(b[44:], s.info) + le.PutUint64(b[48:], s.align) + le.PutUint64(b[56:], s.entsize) +} + +func buildSymtab(syms []symbol, nameOff map[string]uint32) []byte { + b := make([]byte, len(syms)*symEntSz) + le := binary.LittleEndian + for i, s := range syms { + e := b[i*symEntSz:] + le.PutUint32(e[0:], nameOff[s.name]) + e[4] = s.info + e[5] = 0 // st_other + le.PutUint16(e[6:], s.shndx) + le.PutUint64(e[8:], s.value) + le.PutUint64(e[16:], s.size) + } + return b +} + +// buildStrtab lays out the symbol name table, which must begin with a NUL so +// offset 0 names the empty string. +func buildStrtab(syms []symbol) ([]byte, map[string]uint32) { + off := map[string]uint32{"": 0} + b := []byte{0} + for _, s := range syms { + if s.name == "" { + continue + } + off[s.name] = uint32(len(b)) + b = append(b, s.name...) + b = append(b, 0) + } + return b, off +} + +func buildShstrtab(names []string) ([]byte, map[string]uint32) { + off := map[string]uint32{"": 0} + b := []byte{0} + for _, n := range names { + if n == "" { + continue + } + off[n] = uint32(len(b)) + b = append(b, n...) + b = append(b, 0) + } + return b, off +} + +func align8(n uint64) uint64 { return (n + 7) &^ 7 } diff --git a/internal/testbin/elf_test.go b/internal/testbin/elf_test.go new file mode 100644 index 0000000..be042ca --- /dev/null +++ b/internal/testbin/elf_test.go @@ -0,0 +1,73 @@ +package testbin_test + +import ( + "bytes" + "debug/elf" + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/testbin" +) + +// TestTinyELF64IsDeterministic is the property the golden-frame tests rest on. +func TestTinyELF64IsDeterministic(t *testing.T) { + if !bytes.Equal(testbin.TinyELF64(), testbin.TinyELF64()) { + t.Fatal("TinyELF64 is not byte-for-byte reproducible") + } +} + +// TestTinyELF64ParsesAsELF checks the fixture against the standard library +// before asking exex to read it, so a malformed header fails here rather than as +// a confusing golden-frame diff. +func TestTinyELF64ParsesAsELF(t *testing.T) { + f, err := elf.NewFile(bytes.NewReader(testbin.TinyELF64())) + if err != nil { + t.Fatalf("debug/elf rejected the fixture: %v", err) + } + defer f.Close() + + if f.Class != elf.ELFCLASS64 || f.Machine != elf.EM_X86_64 || f.Type != elf.ET_EXEC { + t.Errorf("got class=%v machine=%v type=%v", f.Class, f.Machine, f.Type) + } + for _, name := range []string{".text", ".rodata", ".data", ".symtab", ".strtab", ".shstrtab"} { + if f.Section(name) == nil { + t.Errorf("missing section %s", name) + } + } + syms, err := f.Symbols() + if err != nil { + t.Fatalf("Symbols: %v", err) + } + if len(syms) < 3 { + t.Fatalf("got %d symbols, want _start/helper/msg", len(syms)) + } +} + +// TestTinyELF64OpensInBinfile pins the parts the UI actually renders: an entry +// point inside the executable image, symbols, sections, and extractable strings. +func TestTinyELF64OpensInBinfile(t *testing.T) { + f, err := binfile.Open(testbin.WriteTinyELF64(t)) + if err != nil { + t.Fatalf("binfile.Open: %v", err) + } + defer f.Close() + + if f.Format != binfile.FormatELF { + t.Errorf("format = %v, want ELF", f.Format) + } + if f.Entry() == 0 { + t.Error("entry is zero") + } + if _, ok := f.ExecImage().PosForAddr(f.Entry()); !ok { + t.Errorf("entry 0x%x is not inside the executable image", f.Entry()) + } + if _, ok := f.SymbolAt(f.Entry()); !ok { + t.Error("no symbol covers the entry point") + } + if len(f.Sections) == 0 { + t.Error("no sections") + } + if len(f.Strings()) == 0 { + t.Error("no strings extracted from .rodata") + } +} diff --git a/internal/testbin/testbin.go b/internal/testbin/testbin.go new file mode 100644 index 0000000..e609314 --- /dev/null +++ b/internal/testbin/testbin.go @@ -0,0 +1,17 @@ +package testbin + +import ( + "os" + "path/filepath" + "testing" +) + +// WriteTinyELF64 writes the fixture into t.TempDir() and returns its path. +func WriteTinyELF64(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tiny.elf") + if err := os.WriteFile(path, TinyELF64(), 0o755); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} diff --git a/internal/theme/gen/main.go b/internal/theme/gen/main.go index 93c03b5..671efa3 100644 --- a/internal/theme/gen/main.go +++ b/internal/theme/gen/main.go @@ -1,14 +1,22 @@ //go:build ignore -// Command gen extracts a small semantic palette from every Chroma style and -// writes internal/theme/palettes_gen.go. The generated file contains only colour -// strings (no Chroma import), so it links into the lite build. +// Command gen extracts a small semantic palette from every *curated* Chroma +// style and writes internal/theme/palettes_gen.go. The generated file contains +// only colour strings (no Chroma import), so it links into the lite build. +// +// The curated set comes from internal/chromasubset's styles.txt — the same +// manifest that decides which style XMLs get embedded. Generating from one list +// is what guarantees theme.Names() never offers a theme whose highlighter is +// missing, which used to leave 55 of 75 picker entries silently falling back to +// the minimal highlighter. // // Run via: go generate ./internal/theme package main import ( + "bytes" "fmt" + "go/format" "os" "sort" "strings" @@ -16,6 +24,8 @@ import ( "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/styles" + + "github.com/rabarbra/exex/internal/chromasubset" ) type pal struct { @@ -32,13 +42,20 @@ func hex(s *chroma.Style, tt chroma.TokenType) string { } func main() { - names := styles.Names() + names, err := chromasubset.StyleNames() + if err != nil { + fatal(err) + } sort.Strings(names) + var pals []pal for _, name := range names { s := styles.Get(name) - if s == nil { - continue + // styles.Get falls back to its default style for an unknown name, so an + // asset whose filename doesn't match Chroma's registered style name would + // otherwise be silently replaced by that fallback. + if s == nil || !strings.EqualFold(s.Name, name) { + fatal(fmt.Errorf("styles.txt lists %q, but Chroma registers no style by that name (got %q)", name, styleName(s))) } bg := s.Get(chroma.Background) p := pal{ @@ -67,18 +84,33 @@ func main() { "q": func(s string) string { return `"` + s + `"` }, }).Parse(tmplText)) - f, err := os.Create("palettes_gen.go") + var raw bytes.Buffer + if err := tmpl.Execute(&raw, pals); err != nil { + fatal(err) + } + // The template emits one long map entry per line; gofmt aligns the columns. + // Without this the generated file fails `gofmt -l`, which the repo treats as + // a build error. + src, err := format.Source(raw.Bytes()) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + fatal(fmt.Errorf("gofmt generated source: %w", err)) } - defer f.Close() - if err := tmpl.Execute(f, pals); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + if err := os.WriteFile("palettes_gen.go", src, 0o644); err != nil { + fatal(err) } fmt.Printf("wrote %d palettes\n", len(pals)) - _ = strings.TrimSpace +} + +func styleName(s *chroma.Style) string { + if s == nil { + return "" + } + return s.Name +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } const tmplText = `// Code generated by "go generate ./internal/theme"; DO NOT EDIT. diff --git a/internal/theme/palette.go b/internal/theme/palette.go index 1c9631d..f019711 100644 --- a/internal/theme/palette.go +++ b/internal/theme/palette.go @@ -10,7 +10,16 @@ package theme //go:generate go run gen/main.go -import "sort" +import ( + "sort" + "strings" +) + +// DefaultName is the theme used when none is configured, and the palette every +// other lookup falls back to. Both the UI (internal/ui) and the source +// highlighter (internal/syntax) resolve against it, so it lives here rather than +// as a constant duplicated in each. +const DefaultName = "nord" // Palette is the small set of semantic colours we pull from a Chroma style and // map onto the UI and the built-in syntax highlighter. Every field is a #RRGGBB @@ -76,6 +85,19 @@ func PaletteFor(name string) (Palette, bool) { return Palette{}, false } +// ForegroundFor returns the default text colour of a style, falling back to +// DefaultName's and then to "". Callers use it to colour tokens a style leaves +// unstyled. +func ForegroundFor(name string) string { + if p, ok := PaletteFor(strings.TrimSpace(name)); ok { + return p.Foreground + } + if p, ok := PaletteFor(DefaultName); ok { + return p.Foreground + } + return "" +} + // Names returns the sorted list of known palette (Chroma style) names. func Names() []string { out := make([]string, 0, len(palettes)) diff --git a/internal/theme/palette_test.go b/internal/theme/palette_test.go new file mode 100644 index 0000000..e73e2aa --- /dev/null +++ b/internal/theme/palette_test.go @@ -0,0 +1,39 @@ +package theme + +import ( + "testing" + + "github.com/rabarbra/exex/internal/chromasubset" +) + +// TestNamesMatchManifest pins palettes_gen.go to the curated manifest. This +// package is Chroma-free (it links into the lite build), so it cannot check the +// style XMLs directly — but both sides are generated from styles.txt, and this +// catches a stale palettes_gen.go after the manifest changes. +func TestNamesMatchManifest(t *testing.T) { + want, err := chromasubset.StyleNames() + if err != nil { + t.Fatalf("StyleNames: %v", err) + } + if got := len(Names()); got != len(want) { + t.Errorf("palettes_gen.go has %d palettes, manifest lists %d; run go generate ./internal/theme", got, len(want)) + } + for _, name := range want { + if _, ok := PaletteFor(name); !ok { + t.Errorf("no palette for curated style %q; run go generate ./internal/theme", name) + } + } +} + +func TestPaletteForResolvesEmptyFields(t *testing.T) { + p, ok := PaletteFor("nord") + if !ok { + t.Fatal("nord palette missing") + } + if p.Foreground == "" || p.Background == "" || p.Comment == "" || p.Keyword == "" { + t.Errorf("resolved palette left fields empty: %+v", p) + } + if _, ok := PaletteFor("definitely-not-a-style"); ok { + t.Error("unknown style unexpectedly has a palette") + } +} diff --git a/internal/theme/palettes_gen.go b/internal/theme/palettes_gen.go index 797ad02..633f2db 100644 --- a/internal/theme/palettes_gen.go +++ b/internal/theme/palettes_gen.go @@ -4,57 +4,36 @@ package theme var palettes = map[string]Palette{ "abap": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "#0000ff", Type: "#0000ff", Function: "#000000", Name: "#000000", String: "#55aa22", Number: "#33aaff", Operator: "", Error: "#ff0000"}, - "algol": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "", Type: "", Function: "#666666", Name: "", String: "#666666", Number: "", Operator: "", Error: ""}, - "algol_nu": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "", Type: "", Function: "#666666", Name: "", String: "#666666", Number: "", Operator: "", Error: ""}, "arduino": {Background: "#ffffff", Foreground: "", Comment: "#95a5a6", Keyword: "#728e00", Type: "#00979d", Function: "#d35400", Name: "#434f54", String: "#7f8c8d", Number: "#8a7b52", Operator: "#728e00", Error: "#a61717"}, "ashen": {Background: "#121212", Foreground: "#b4b4b4", Comment: "#737373", Keyword: "#b14242", Type: "#4a8b8b", Function: "#b4b4b4", Name: "#b4b4b4", String: "#df6464", Number: "#4a8b8b", Operator: "#d87c4a", Error: "#c53030"}, "aura-theme-dark": {Background: "#15141b", Foreground: "#edecee", Comment: "#6d6d6d", Keyword: "#a277ff", Type: "#82e2ff", Function: "#ffca85", Name: "#edecee", String: "#61ffca", Number: "#61ffca", Operator: "#a277ff", Error: "#ff6767"}, - "aura-theme-dark-soft": {Background: "#15141b", Foreground: "#bdbdbd", Comment: "#6d6d6d", Keyword: "#8464c6", Type: "#6cb2c7", Function: "#c7a06f", Name: "#bdbdbd", String: "#54c59f", Number: "#54c59f", Operator: "#8464c6", Error: "#c55858"}, - "autumn": {Background: "#ffffff", Foreground: "", Comment: "#aaaaaa", Keyword: "#0000aa", Type: "#00aaaa", Function: "#00aa00", Name: "", String: "#aa5500", Number: "#009999", Operator: "", Error: "#ff0000"}, - "average": {Background: "#000000", Foreground: "#757575", Comment: "#757575", Keyword: "#ec0000", Type: "#5f5fff", Function: "#5f5fff", Name: "#757575", String: "#008900", Number: "#008900", Operator: "#ec0000", Error: "#ec0000"}, "base16-snazzy": {Background: "#282a36", Foreground: "#e2e4e5", Comment: "#78787e", Keyword: "#ff6ac1", Type: "#9aedfe", Function: "#57c7ff", Name: "#e2e4e5", String: "#5af78e", Number: "#ff9f43", Operator: "#ff6ac1", Error: "#ff5c57"}, - "borland": {Background: "#ffffff", Foreground: "", Comment: "#008800", Keyword: "#000080", Type: "#000080", Function: "", Name: "", String: "#0000ff", Number: "#0000ff", Operator: "", Error: "#a61717"}, - "bw": {Background: "#ffffff", Foreground: "", Comment: "", Keyword: "", Type: "", Function: "", Name: "", String: "", Number: "", Operator: "", Error: ""}, "catppuccin-frappe": {Background: "#303446", Foreground: "#c6d0f5", Comment: "#737994", Keyword: "#ca9ee6", Type: "#e78284", Function: "#8caaee", Name: "#c6d0f5", String: "#a6d189", Number: "#ef9f76", Operator: "#99d1db", Error: "#e78284"}, "catppuccin-latte": {Background: "#eff1f5", Foreground: "#4c4f69", Comment: "#9ca0b0", Keyword: "#8839ef", Type: "#d20f39", Function: "#1e66f5", Name: "#4c4f69", String: "#40a02b", Number: "#fe640b", Operator: "#04a5e5", Error: "#d20f39"}, "catppuccin-macchiato": {Background: "#24273a", Foreground: "#cad3f5", Comment: "#6e738d", Keyword: "#c6a0f6", Type: "#ed8796", Function: "#8aadf4", Name: "#cad3f5", String: "#a6da95", Number: "#f5a97f", Operator: "#91d7e3", Error: "#ed8796"}, "catppuccin-mocha": {Background: "#1e1e2e", Foreground: "#cdd6f4", Comment: "#6c7086", Keyword: "#cba6f7", Type: "#f38ba8", Function: "#89b4fa", Name: "#cdd6f4", String: "#a6e3a1", Number: "#fab387", Operator: "#89dceb", Error: "#f38ba8"}, - "colorful": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "#008800", Type: "#333399", Function: "#0066bb", Name: "", String: "", Number: "#6600ee", Operator: "#333333", Error: "#ff0000"}, "darcula": {Background: "#2b2b2b", Foreground: "#a9b7c6", Comment: "#808080", Keyword: "#cc7832", Type: "#cc7832", Function: "#ffc66d", Name: "#a9b7c6", String: "#6a8759", Number: "#6897bb", Operator: "#a9b7c6", Error: "#ff6b68"}, "doom-one": {Background: "#282c34", Foreground: "#b0c4de", Comment: "#8a93a5", Keyword: "#c678dd", Type: "#ef8383", Function: "#00b1f7", Name: "#c1abea", String: "#98c379", Number: "#d19a66", Operator: "#c7bf54", Error: "#b0c4de"}, "doom-one2": {Background: "#282c34", Foreground: "#b0c4de", Comment: "#8a93a5", Keyword: "#76a9f9", Type: "#e5c07b", Function: "#00b1f7", Name: "#aa89ea", String: "#98c379", Number: "#d19a66", Operator: "#54b1c7", Error: "#b0c4de"}, "dracula": {Background: "#282a36", Foreground: "#f8f8f2", Comment: "#6272a4", Keyword: "#ff79c6", Type: "#8be9fd", Function: "#50fa7b", Name: "#f8f8f2", String: "#f1fa8c", Number: "#bd93f9", Operator: "#ff79c6", Error: "#f8f8f2"}, - "emacs": {Background: "#f8f8f8", Foreground: "", Comment: "#008800", Keyword: "#aa22ff", Type: "#00bb00", Function: "#00a000", Name: "", String: "#bb4444", Number: "#666666", Operator: "#666666", Error: ""}, "evergarden": {Background: "#252b2e", Foreground: "#d6cbb4", Comment: "#859289", Keyword: "#e67e80", Type: "#dbbc7f", Function: "#b2c98f", Name: "#d6cbb4", String: "#b2c98f", Number: "#d699b6", Operator: "#7a8478", Error: "#d6cbb4"}, - "friendly": {Background: "#f0f0f0", Foreground: "", Comment: "#60a0b0", Keyword: "#007020", Type: "#902000", Function: "#06287e", Name: "", String: "#4070a0", Number: "#40a070", Operator: "#666666", Error: ""}, "fruity": {Background: "#111111", Foreground: "#ffffff", Comment: "#008800", Keyword: "#fb660a", Type: "#cdcaa9", Function: "#ff0086", Name: "#ffffff", String: "#0086d2", Number: "#0086f7", Operator: "#ffffff", Error: "#ffffff"}, - "github": {Background: "#f7f7f7", Foreground: "", Comment: "#57606a", Keyword: "#cf222e", Type: "#cf222e", Function: "#6639ba", Name: "", String: "#0a3069", Number: "#0550ae", Operator: "#0550ae", Error: "#f6f8fa"}, "github-dark": {Background: "#0d1117", Foreground: "#e6edf3", Comment: "#8b949e", Keyword: "#ff7b72", Type: "#ff7b72", Function: "#d2a8ff", Name: "#e6edf3", String: "#a5d6ff", Number: "#a5d6ff", Operator: "#ff7b72", Error: "#f85149"}, "gruvbox": {Background: "#282828", Foreground: "#ebdbb2", Comment: "#928374", Keyword: "#fe8019", Type: "#fabd2f", Function: "#fabd2f", Name: "#ebdbb2", String: "#b8bb26", Number: "#d3869b", Operator: "#fe8019", Error: "#ebdbb2"}, "gruvbox-light": {Background: "#fbf1c7", Foreground: "#3c3836", Comment: "#928374", Keyword: "#af3a03", Type: "#b57614", Function: "#b57614", Name: "#3c3836", String: "#79740e", Number: "#8f3f71", Operator: "#af3a03", Error: "#3c3836"}, - "hr_high_contrast": {Background: "", Foreground: "#000000", Comment: "#5a8349", Keyword: "#467faf", Type: "#467faf", Function: "#ffffff", Name: "#ffffff", String: "#a87662", Number: "#ffffff", Operator: "#e4e400", Error: "#000000"}, - "hrdark": {Background: "", Foreground: "#1d2432", Comment: "#828b96", Keyword: "#ff636f", Type: "#ff636f", Function: "#58a1dd", Name: "#58a1dd", String: "#a6be9d", Number: "#a6be9d", Operator: "#ff636f", Error: "#1d2432"}, - "igor": {Background: "#ffffff", Foreground: "", Comment: "#ff0000", Keyword: "#0000ff", Type: "#0000ff", Function: "#c34e00", Name: "", String: "#009c00", Number: "", Operator: "", Error: ""}, "kanagawa-dragon": {Background: "#181616", Foreground: "#c5c9c5", Comment: "#737c73", Keyword: "#8992a7", Type: "#8ea4a2", Function: "#8ba4b0", Name: "#c5c9c5", String: "#8a9a7b", Number: "#a292a3", Operator: "#c4746e", Error: "#e82424"}, "kanagawa-lotus": {Background: "#f2ecbc", Foreground: "#545464", Comment: "#8a8980", Keyword: "#624c83", Type: "#597b75", Function: "#4d699b", Name: "#545464", String: "#6f894e", Number: "#b35b79", Operator: "#836f4a", Error: "#e82424"}, "kanagawa-wave": {Background: "#1f1f28", Foreground: "#dcd7ba", Comment: "#727169", Keyword: "#957fb8", Type: "#7aa89f", Function: "#7e9cd8", Name: "#dcd7ba", String: "#98bb6c", Number: "#d27e99", Operator: "#c0a36e", Error: "#e82424"}, - "lovelace": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "#2838b0", Type: "#2838b0", Function: "#785840", Name: "", String: "#b83838", Number: "#444444", Operator: "#666666", Error: ""}, - "manni": {Background: "#f0f3f3", Foreground: "", Comment: "#0099ff", Keyword: "#006699", Type: "#007788", Function: "#cc00ff", Name: "", String: "#cc3300", Number: "#ff6600", Operator: "#555555", Error: "#aa0000"}, "modus-operandi": {Background: "#ffffff", Foreground: "#000000", Comment: "#505050", Keyword: "#5317ac", Type: "#005a5f", Function: "#721045", Name: "#000000", String: "#2544bb", Number: "#0000c0", Operator: "#00538b", Error: "#000000"}, "modus-vivendi": {Background: "#000000", Foreground: "#ffffff", Comment: "#a8a8a8", Keyword: "#b6a0ff", Type: "#6ae4b9", Function: "#feacd0", Name: "#ffffff", String: "#79a8ff", Number: "#00bcff", Operator: "#00d3d0", Error: "#ffffff"}, "monokai": {Background: "#272822", Foreground: "#f8f8f2", Comment: "#75715e", Keyword: "#66d9ef", Type: "#66d9ef", Function: "#a6e22e", Name: "#f8f8f2", String: "#e6db74", Number: "#ae81ff", Operator: "#f92672", Error: "#960050"}, "monokailight": {Background: "#fafafa", Foreground: "#272822", Comment: "#75715e", Keyword: "#00a8c8", Type: "#00a8c8", Function: "#75af00", Name: "#111111", String: "#d88200", Number: "#ae81ff", Operator: "#f92672", Error: "#960050"}, - "murphy": {Background: "#ffffff", Foreground: "", Comment: "#666666", Keyword: "#228899", Type: "#6666ff", Function: "#55eedd", Name: "", String: "", Number: "#6600ee", Operator: "#333333", Error: "#ff0000"}, "native": {Background: "#202020", Foreground: "#d0d0d0", Comment: "#999999", Keyword: "#6ab825", Type: "#6ab825", Function: "#447fcf", Name: "#d0d0d0", String: "#ed9d13", Number: "#3677a9", Operator: "#d0d0d0", Error: "#a61717"}, "nord": {Background: "#2e3440", Foreground: "#d8dee9", Comment: "#616e87", Keyword: "#81a1c1", Type: "#81a1c1", Function: "#88c0d0", Name: "#d8dee9", String: "#a3be8c", Number: "#b48ead", Operator: "#81a1c1", Error: "#bf616a"}, "nordic": {Background: "#242933", Foreground: "#bbc3d4", Comment: "#4c566a", Keyword: "#d08770", Type: "#d08770", Function: "#88c0d0", Name: "#bbc3d4", String: "#a3be8c", Number: "#b48ead", Operator: "#5e81ac", Error: "#c5727a"}, "onedark": {Background: "#282c34", Foreground: "#abb2bf", Comment: "#7f848e", Keyword: "#c678dd", Type: "#e5c07b", Function: "#61afef", Name: "#e06c75", String: "#98c379", Number: "#d19a66", Operator: "#56b6c2", Error: "#abb2bf"}, - "onesenterprise": {Background: "", Foreground: "#000000", Comment: "#008000", Keyword: "#ff0000", Type: "#ff0000", Function: "#0000ff", Name: "#0000ff", String: "#000000", Number: "#000000", Operator: "#ff0000", Error: "#000000"}, "paraiso-dark": {Background: "#2f1e2e", Foreground: "#e7e9db", Comment: "#776e71", Keyword: "#815ba4", Type: "#fec418", Function: "#06b6ef", Name: "#e7e9db", String: "#48b685", Number: "#f99b15", Operator: "#5bc4bf", Error: "#ef6155"}, "paraiso-light": {Background: "#e7e9db", Foreground: "#2f1e2e", Comment: "#8d8687", Keyword: "#815ba4", Type: "#fec418", Function: "#06b6ef", Name: "#2f1e2e", String: "#48b685", Number: "#f99b15", Operator: "#5bc4bf", Error: "#ef6155"}, - "pastie": {Background: "#ffffff", Foreground: "", Comment: "#888888", Keyword: "#008800", Type: "#888888", Function: "#0066bb", Name: "", String: "#dd2200", Number: "#0000dd", Operator: "", Error: "#a61717"}, - "perldoc": {Background: "#eeeedd", Foreground: "", Comment: "#228b22", Keyword: "#8b008b", Type: "#00688b", Function: "#008b45", Name: "", String: "#cd5555", Number: "#b452cd", Operator: "", Error: "#a61717"}, - "pygments": {Background: "", Foreground: "", Comment: "#408080", Keyword: "#008000", Type: "#b00040", Function: "#0000ff", Name: "", String: "#ba2121", Number: "#666666", Operator: "#666666", Error: ""}, "rainbow_dash": {Background: "#ffffff", Foreground: "#4d4d4d", Comment: "#0080ff", Keyword: "#2c5dcd", Type: "#5918bb", Function: "#ff8000", Name: "#4d4d4d", String: "#00cc66", Number: "#5918bb", Operator: "#2c5dcd", Error: "#ffffff"}, "rose-pine": {Background: "#191724", Foreground: "#e0def4", Comment: "#6e6a86", Keyword: "#31748f", Type: "#31748f", Function: "#ebbcba", Name: "#ebbcba", String: "#f6c177", Number: "#f6c177", Operator: "#908caa", Error: "#eb6f92"}, "rose-pine-dawn": {Background: "#faf4ed", Foreground: "#575279", Comment: "#9893a5", Keyword: "#286983", Type: "#286983", Function: "#d7827e", Name: "#d7827e", String: "#ea9d34", Number: "#ea9d34", Operator: "#797593", Error: "#b4637a"}, @@ -69,10 +48,6 @@ var palettes = map[string]Palette{ "tokyonight-day": {Background: "#e1e2e7", Foreground: "#3760bf", Comment: "#a1a6c5", Keyword: "#9854f1", Type: "#0db9d7", Function: "#2e7de9", Name: "#3760bf", String: "#587539", Number: "#8c6c3e", Operator: "#587539", Error: "#c64343"}, "tokyonight-moon": {Background: "#222436", Foreground: "#c8d3f5", Comment: "#444a73", Keyword: "#c099ff", Type: "#4fd6be", Function: "#82aaff", Name: "#c8d3f5", String: "#c3e88d", Number: "#ffc777", Operator: "#c3e88d", Error: "#c53b53"}, "tokyonight-night": {Background: "#1a1b26", Foreground: "#c0caf5", Comment: "#414868", Keyword: "#bb9af7", Type: "#41a6b5", Function: "#7aa2f7", Name: "#c0caf5", String: "#9ece6a", Number: "#e0af68", Operator: "#9ece6a", Error: "#db4b4b"}, - "tokyonight-storm": {Background: "#1a1b26", Foreground: "#c0caf5", Comment: "#414868", Keyword: "#bb9af7", Type: "#41a6b5", Function: "#7aa2f7", Name: "#c0caf5", String: "#9ece6a", Number: "#e0af68", Operator: "#9ece6a", Error: "#db4b4b"}, - "trac": {Background: "#ffffff", Foreground: "", Comment: "#999988", Keyword: "", Type: "#445588", Function: "#990000", Name: "", String: "#bb8844", Number: "#009999", Operator: "", Error: "#a61717"}, - "vim": {Background: "#000000", Foreground: "#cccccc", Comment: "#000080", Keyword: "#cdcd00", Type: "#00cd00", Function: "#cccccc", Name: "#cccccc", String: "#cd0000", Number: "#cd00cd", Operator: "#3399cc", Error: "#cccccc"}, - "vs": {Background: "#ffffff", Foreground: "", Comment: "#008000", Keyword: "#0000ff", Type: "#2b91af", Function: "", Name: "", String: "#a31515", Number: "", Operator: "", Error: ""}, "vulcan": {Background: "#282c34", Foreground: "#c9c9c9", Comment: "#3e4460", Keyword: "#7fbaf5", Type: "#57c7ff", Function: "#57c7ff", Name: "#c9c9c9", String: "#82cc6a", Number: "#56b6c2", Operator: "#bc74c4", Error: "#cf5967"}, "witchhazel": {Background: "#433e56", Foreground: "#f8f8f2", Comment: "#b0bec5", Keyword: "#c2ffdf", Type: "#c2ffdf", Function: "#ceb1ff", Name: "#f8f8f2", String: "#1bc5e0", Number: "#c5a3ff", Operator: "#ffb8d1", Error: "#960050"}, "xcode": {Background: "#ffffff", Foreground: "", Comment: "#177500", Keyword: "#a90d91", Type: "#a90d91", Function: "#000000", Name: "#000000", String: "#c41a16", Number: "#1c01ce", Operator: "#000000", Error: "#000000"}, diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 45e0118..11a2814 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" ) // TestRenderAllViews drives the model through every view (and some navigation) @@ -40,11 +41,7 @@ func TestRenderAllViews(t *testing.T) { pump := func(msg tea.Msg) { t.Helper() model, _ = model.Update(msg) - if mm, ok := model.(*Model); ok && mm.disasmDecoding { - addr := mm.disasmPendingAddr - win, insts := mm.decodeDisasmAt(addr, mm.disasmLeadBytes()) - model, _ = model.Update(disasmReadyMsg{addr: addr, posLo: win.Start, posHi: win.End, insts: insts}) - } + model = settleDisasmDecode(model) } pump(tea.WindowSizeMsg{Width: 120, Height: 40}) @@ -59,10 +56,10 @@ func TestRenderAllViews(t *testing.T) { assertDisasmBudget := func() { t.Helper() mm, ok := model.(*Model) - if !ok || len(mm.disasmInst) == 0 { + if !ok || len(mm.dasm.Inst) == 0 { return } - if got := mm.disasmPosHi - mm.disasmPosLo; got > mm.disasmMaxBytes { + if got := mm.dasm.PosHi - mm.dasm.PosLo; got > mm.disasmMaxBytes { t.Fatalf("disasm window = %d bytes, budget = %d", got, mm.disasmMaxBytes) } } @@ -210,39 +207,39 @@ func TestCtrlENavigatesDisasmToEnd(t *testing.T) { m.width, m.height = 120, 40 m.disasmMaxBytes = 16 << 10 m.jumpDisasmBoundary(false) - if len(m.disasmInst) < 2 { + if len(m.dasm.Inst) < 2 { t.Skip("not enough disassembly to test end navigation") } - m.disasmCur = 0 + m.dasm.Cur = 0 model, _ := m.handleKey(keyPress("ctrl+e")) m = model.(*Model) - if got, want := m.disasmCur, len(m.disasmInst)-1; got != want { + if got, want := m.dasm.Cur, len(m.dasm.Inst)-1; got != want { t.Fatalf("ctrl+e disasm cursor = %d, want %d", got, want) } _ = m.View() - rowHeight := func(i int) int { return m.disasmInstVisualHeight(i, m.disasmRenderWidth()) } - if got, want := m.disasmTop, maxViewportTop(len(m.disasmInst), m.disasmViewportHeight(), rowHeight); got != want { + rowHeight := m.disasmRowHeight(m.disasmRenderWidth()) + if got, want := m.dasm.Top, layout.MaxViewportTop(len(m.dasm.Inst), m.disasmViewportHeight(), rowHeight); got != want { t.Fatalf("ctrl+e disasm top = %d, want bottom-aligned %d", got, want) } - endCur := m.disasmCur - endTop := m.disasmTop + endCur := m.dasm.Cur + endTop := m.dasm.Top model, _ = m.handleKey(keyPress("up")) m = model.(*Model) - if got, want := m.disasmCur, endCur-1; got != want { + if got, want := m.dasm.Cur, endCur-1; got != want { t.Fatalf("up after ctrl+e cursor = %d, want %d", got, want) } - if got := m.disasmTop; got != endTop { + if got := m.dasm.Top; got != endTop { t.Fatalf("up after ctrl+e top = %d, want unchanged %d", got, endTop) } model, _ = m.handleKey(keyPress("ctrl+e")) m = model.(*Model) _ = m.View() - endTop = m.disasmTop - endLo := m.disasmPosLo - endAddr := m.disasmInst[m.disasmCur].Addr + endTop = m.dasm.Top + endLo := m.dasm.PosLo + endAddr := m.dasm.Inst[m.dasm.Cur].Addr m.wheelSuppressUntil = time.Time{} model, _ = m.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp, X: 2, Y: 5})) m = model.(*Model) @@ -250,13 +247,13 @@ func TestCtrlENavigatesDisasmToEnd(t *testing.T) { t.Fatal("wheel up after ctrl+e did not detach viewport") } if endTop == 0 && endLo > 0 { - if got := m.disasmPosLo; got >= endLo { + if got := m.dasm.PosLo; got >= endLo { t.Fatalf("wheel up after ctrl+e posLo = %d, want before %d", got, endLo) } - if got := m.disasmInst[m.disasmCur].Addr; got > endAddr { + if got := m.dasm.Inst[m.dasm.Cur].Addr; got > endAddr { t.Fatalf("wheel up after ctrl+e cursor addr = 0x%x, want at or before 0x%x", got, endAddr) } - } else if got := m.disasmTop; got >= endTop { + } else if got := m.dasm.Top; got >= endTop { t.Fatalf("wheel up after ctrl+e top = %d, want less than %d", got, endTop) } } @@ -281,11 +278,11 @@ func TestMouseWheelOverRightDisasmPaneScrollsRightPane(t *testing.T) { if !m.rightPaneActive() { t.Skip("right pane is not active") } - m.rightScroll = 9 + m.dasm.RightScroll = 9 model, _ := m.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp, X: m.width - 2, Y: 10})) m = model.(*Model) - if got := m.rightScroll; got != 6 { + if got := m.dasm.RightScroll; got != 6 { t.Fatalf("rightScroll after wheel up = %d, want 6", got) } } @@ -309,19 +306,18 @@ func TestGotoChromeMainOnChromiumBinary(t *testing.T) { model, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) mm := model.(*Model) - mm.gotoInput.SetValue("ChromeMain") - mm.recomputeGoto() - if len(mm.gotoResults) == 0 { + mm.palette.SetQuery(mm, "ChromeMain") + if len(mm.palette.Results()) == 0 { t.Fatal("expected goto results for ChromeMain") } - mm.activateGoto() + mm.palette.Activate(mm) if mm.mode != modeDisasm { t.Fatalf("mode = %v, want disasm", mm.mode) } - if len(mm.disasmInst) == 0 { + if len(mm.dasm.Inst) == 0 { t.Fatal("expected disasm window after goto") } - addr := mm.disasmInst[mm.disasmCur].Addr + addr := mm.dasm.Inst[mm.dasm.Cur].Addr sym, ok := mm.file.SymbolAt(addr) if !ok { t.Fatalf("no symbol at current disasm address 0x%x", addr) @@ -329,17 +325,17 @@ func TestGotoChromeMainOnChromiumBinary(t *testing.T) { if sym.Display() != "ChromeMain" { t.Fatalf("landed on %q at 0x%x, want ChromeMain", sym.Display(), addr) } - if got := mm.disasmPosHi - mm.disasmPosLo; got > mm.disasmMaxBytes { + if got := mm.dasm.PosHi - mm.dasm.PosLo; got > mm.disasmMaxBytes { t.Fatalf("disasm window = %d bytes, budget = %d", got, mm.disasmMaxBytes) } mm.jumpDisasmBoundary(false) mm.searchQuery = "ChromeMain" runModelCmd(t, mm, mm.runSearch(true, true)) - if len(mm.disasmInst) == 0 { + if len(mm.dasm.Inst) == 0 { t.Fatal("expected disasm window after search") } - addr = mm.disasmInst[mm.disasmCur].Addr + addr = mm.dasm.Inst[mm.dasm.Cur].Addr sym, ok = mm.file.SymbolAt(addr) if !ok || sym.Display() != "ChromeMain" { t.Fatalf("search landed on %q at 0x%x, want ChromeMain", sym.Display(), addr) @@ -365,34 +361,34 @@ func TestSearchMovsblOnChromiumBinary(t *testing.T) { m.jumpDisasmBoundary(false) m.searchQuery = "movsbl" runModelCmd(t, m, m.runSearch(true, true)) - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { t.Fatal("expected disasm window after search") } - got := strings.ToLower(m.disasmInst[m.disasmCur].Text) + got := strings.ToLower(m.dasm.Inst[m.dasm.Cur].Text) if !strings.Contains(got, "movsbl") { t.Fatalf("search landed on %q, want movsbl", got) } - if got := m.disasmPosHi - m.disasmPosLo; got > m.disasmMaxBytes { + if got := m.dasm.PosHi - m.dasm.PosLo; got > m.disasmMaxBytes { t.Fatalf("disasm window = %d bytes, budget = %d", got, m.disasmMaxBytes) } - first := m.disasmInst[m.disasmCur].Addr - if len(m.searchResults.hits) < 2 { - t.Fatalf("expected cached movsbl hits, got %d", len(m.searchResults.hits)) + first := m.dasm.Inst[m.dasm.Cur].Addr + if len(m.searchResults.Hits()) < 2 { + t.Fatalf("expected cached movsbl hits, got %d", len(m.searchResults.Hits())) } cmd := m.runSearch(true, false) if m.searchRunning { t.Fatal("expected cached movsbl hit not to start background search") } runModelCmd(t, m, cmd) - second := m.disasmInst[m.disasmCur].Addr + second := m.dasm.Inst[m.dasm.Cur].Addr if second <= first { t.Fatalf("expected later cached movsbl hit, got 0x%x after 0x%x", second, first) } for i := 0; i < 6; i++ { runModelCmd(t, m, m.runSearch(true, false)) } - if len(m.searchResults.hits) < 6 { - t.Fatalf("expected several cached movsbl hits, got %d", len(m.searchResults.hits)) + if len(m.searchResults.Hits()) < 6 { + t.Fatalf("expected several cached movsbl hits, got %d", len(m.searchResults.Hits())) } for i := 0; i < 4; i++ { cmd = m.runSearch(false, false) @@ -422,17 +418,17 @@ func TestSearchBadbeefBacktracksFromEndUsingCache(t *testing.T) { m.jumpDisasmBoundary(false) m.searchQuery = "badbeef" runModelCmd(t, m, m.runSearch(true, true)) - first := m.disasmInst[m.disasmCur].Addr - if !strings.Contains(strings.ToLower(m.disasmInst[m.disasmCur].Text), "badbeef") { - t.Fatalf("first hit = %q, want badbeef", m.disasmInst[m.disasmCur].Text) + first := m.dasm.Inst[m.dasm.Cur].Addr + if !strings.Contains(strings.ToLower(m.dasm.Inst[m.dasm.Cur].Text), "badbeef") { + t.Fatalf("first hit = %q, want badbeef", m.dasm.Inst[m.dasm.Cur].Text) } runModelCmd(t, m, m.runSearch(true, false)) - second := m.disasmInst[m.disasmCur].Addr + second := m.dasm.Inst[m.dasm.Cur].Addr if second <= first { t.Fatalf("second hit 0x%x should be after first 0x%x", second, first) } runModelCmd(t, m, m.runSearch(true, false)) - if !m.searchResults.forwardExhausted { + if !m.searchResults.Exhausted(true) { t.Fatal("expected forward search to be exhausted after second badbeef hit") } cmd := m.runSearch(false, false) @@ -440,7 +436,7 @@ func TestSearchBadbeefBacktracksFromEndUsingCache(t *testing.T) { t.Fatal("expected backward repeat after end to use cache") } runModelCmd(t, m, cmd) - if got := m.disasmInst[m.disasmCur].Addr; got != second { + if got := m.dasm.Inst[m.dasm.Cur].Addr; got != second { t.Fatalf("first backward cached hit = 0x%x, want last hit 0x%x", got, second) } cmd = m.runSearch(false, false) @@ -448,7 +444,7 @@ func TestSearchBadbeefBacktracksFromEndUsingCache(t *testing.T) { t.Fatal("expected second backward repeat after end to use cache") } runModelCmd(t, m, cmd) - if got := m.disasmInst[m.disasmCur].Addr; got != first { + if got := m.dasm.Inst[m.dasm.Cur].Addr; got != first { t.Fatalf("second backward cached hit = 0x%x, want first hit 0x%x", got, first) } cmd = m.runSearch(false, false) @@ -458,7 +454,7 @@ func TestSearchBadbeefBacktracksFromEndUsingCache(t *testing.T) { if cmd != nil { runModelCmd(t, m, cmd) } - if got := m.disasmInst[m.disasmCur].Addr; got != first { + if got := m.dasm.Inst[m.dasm.Cur].Addr; got != first { t.Fatalf("backward repeat before first hit moved to 0x%x, want to stay at first hit 0x%x", got, first) } } @@ -480,18 +476,18 @@ func TestDisasmNavigationAutoLoadsVisibleScreenOnChromiumBinary(t *testing.T) { m.width, m.height = 120, 20 m.jumpDisasmBoundary(false) - m.disasmCur = len(m.disasmInst) - 1 - _, oldHi := m.disasmPosLo, m.disasmPosHi + m.dasm.Cur = len(m.dasm.Inst) - 1 + _, oldHi := m.dasm.PosLo, m.dasm.PosHi m.updateDisasm("down") - if m.disasmPosHi <= oldHi { + if m.dasm.PosHi <= oldHi { t.Fatal("expected navigation to load more code below") } m.jumpDisasmBoundary(true) - m.disasmCur = 0 - oldLo, _ := m.disasmPosLo, m.disasmPosHi + m.dasm.Cur = 0 + oldLo, _ := m.dasm.PosLo, m.dasm.PosHi m.updateDisasm("up") - if m.disasmPosLo >= oldLo { + if m.dasm.PosLo >= oldLo { t.Fatal("expected navigation to load more code above") } } diff --git a/internal/ui/archive.go b/internal/ui/archive.go index 1beb265..c2ad473 100644 --- a/internal/ui/archive.go +++ b/internal/ui/archive.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" ) // archiveState holds the archive's members and the Info view's members-list mode. @@ -67,9 +68,7 @@ func (m *Model) enterMembersList() { } // loadArchiveMember parses the i-th member and returns a fresh model for it (the -// archive context carries over), showing that member's header info. Mirrors the -// fat-Mach-O arch switch: the previous member's image stays mapped, so any -// in-flight background decode is safe. +// archive context carries over), showing that member's header info. func (m *Model) loadArchiveMember(i int) (tea.Model, tea.Cmd) { if i < 0 || i >= len(m.archiveMembers) { return m, nil @@ -82,6 +81,7 @@ func (m *Model) loadArchiveMember(i int) (tea.Model, tea.Cmd) { } nm, err := New(f, Options{Config: &m.cfg}) if err != nil { + f.Close() m.setStatus("member "+mem.Name+": "+err.Error(), true) return m, nil } @@ -91,7 +91,10 @@ func (m *Model) loadArchiveMember(i int) (tea.Model, tea.Cmd) { nm.memberSel = i nm.infoMembers = false // show the loaded member's info; t/tab returns to the list nm.width, nm.height = m.width, m.height + nm.fileStack = append([]*Model(nil), m.fileStack...) + nm.fileLabel = m.fileLabel nm.setStatus(fmt.Sprintf("member %d/%d: %s", i+1, len(m.archiveMembers), mem.Name), false) + m.retireFile() return nm, nm.Init() } @@ -105,7 +108,7 @@ func (m *Model) updateMembersList(key string) (tea.Model, tea.Cmd) { m.infoMembers = false // back to the loaded member's header return m, nil } - navKey(&m.memberSel, len(m.archiveMembers), m.listPage(), key) + layout.NavKey(&m.memberSel, len(m.archiveMembers), m.listPage(), key) return m, nil } @@ -117,13 +120,13 @@ func (m *Model) renderMembersList() string { rowW := max(1, m.width-2) hdr := fmt.Sprintf(" %s — %d members", filepath.Base(m.archivePath), len(mems)) - rows := []string{m.tableHeader(fitANSIWidth(hdr, rowW)), ""} + rows := []string{m.tableHeader(layout.FitANSIWidth(hdr, rowW)), ""} visible := max(1, bodyH-2) // header + blank top := m.visualTopForView(m.memberSel, m.memberTop, len(mems), visible, oneRow) m.memberTop = top end := min(top+visible, len(mems)) - nameW := clamp(rowW-26, 16, 90) + nameW := layout.Clamp(rowW-26, 16, 90) for i := top; i < end; i++ { mem := mems[i] mark := " " @@ -131,14 +134,14 @@ func (m *Model) renderMembersList() string { mark = "●" } line := fmt.Sprintf("%s %s %9d %-6s", - mark, padVisual(truncateMiddle(mem.Name, nameW), nameW), len(mem.Data), memberFormatTag(mem.Data)) - line = padVisual(line, rowW) + mark, layout.PadVisual(layout.TruncateMiddle(mem.Name, nameW), nameW), len(mem.Data), memberFormatTag(mem.Data)) + line = layout.PadVisual(line, rowW) if i == m.memberSel { line = m.theme.tableSelStyle.Render(ansi.Strip(line)) } rows = append(rows, line) } - return padBodyRows(rows, m.width, bodyH) + return layout.PadBodyRows(rows, m.width, bodyH) } // memberFormatTag names a member's container format from its magic bytes — cheap diff --git a/internal/ui/asmhl/asmhl.go b/internal/ui/asmhl/asmhl.go new file mode 100644 index 0000000..dd80c56 --- /dev/null +++ b/internal/ui/asmhl/asmhl.go @@ -0,0 +1,92 @@ +// Package asmhl highlights a single line of disassembled instruction text. +// +// It exists to keep the `lite` build tag out of internal/ui. The default build +// tokenises with Chroma's assembly lexers; the lite build uses a small +// theme-driven scanner and drops Chroma entirely. Both satisfy Highlighter, so +// the shell holds one interface value and never mentions a build tag. +// +// Whichever it is, followable address literals are drawn in their link colour +// rather than the operand-token colours. The caller finds those spans (it needs +// the binary to know which addresses are mapped) and passes them in. +package asmhl + +import ( + "strings" + + "charm.land/lipgloss/v2" + + "github.com/rabarbra/exex/internal/arch" + "github.com/rabarbra/exex/internal/disasm" +) + +// Span marks a run of instruction text — a followable mapped address — that +// should be drawn in a link colour. Offsets are byte indices into the text. +type Span struct { + Start int + End int + Style lipgloss.Style +} + +// Styles is the theme vocabulary a highlighter draws with. Class colours the +// mnemonic by instruction class; the rest colour operand tokens (used by the +// lite highlighter, and by the Chroma one's fallback path). +type Styles struct { + Class func(disasm.InstClass) lipgloss.Style + Plain lipgloss.Style // whitespace, punctuation, size specifiers + Register lipgloss.Style + Number lipgloss.Style +} + +// Highlighter renders one instruction's text. +type Highlighter interface { + // Render styles text, overlaying spans on top of the token colours. spans must + // be ordered by Start and must not overlap. + Render(text string, class disasm.InstClass, spans []Span) string +} + +// New builds the highlighter for the current build, theme and architecture. +// +// - themeName / fallbackFG select the Chroma style (default build only). +// - a selects the assembly lexer (default build only). +// +// The lite build ignores both and colours from st alone. Callers rebuild the +// highlighter when the theme changes rather than mutating it, so its caches are +// never stale. +func New(themeName, fallbackFG string, a arch.Arch, st Styles) Highlighter { + return newHighlighter(themeName, fallbackFG, a, st) +} + +// spanAt returns the span covering byte index i, if any. +func spanAt(spans []Span, i int) (Span, bool) { + for _, s := range spans { + if i >= s.Start && i < s.End { + return s, true + } + } + return Span{}, false +} + +// renderByClass colours text by its instruction class, with the link spans drawn +// in their own style. No per-token highlighting: it is the Chroma build's +// fallback when the theme has no bundled style, no asm lexer matches, or +// tokenising fails. +func renderByClass(text string, class disasm.InstClass, spans []Span, st Styles) string { + classSt := st.Class(class) + var b strings.Builder + from, si := 0, 0 + for from < len(text) { + if si < len(spans) && spans[si].Start == from { + b.WriteString(spans[si].Style.Render(text[from:spans[si].End])) + from = spans[si].End + si++ + continue + } + next := len(text) + if si < len(spans) { + next = spans[si].Start + } + b.WriteString(classSt.Render(text[from:next])) + from = next + } + return b.String() +} diff --git a/internal/ui/asmhl/asmhl_chroma.go b/internal/ui/asmhl/asmhl_chroma.go new file mode 100644 index 0000000..76186df --- /dev/null +++ b/internal/ui/asmhl/asmhl_chroma.go @@ -0,0 +1,138 @@ +//go:build !lite + +package asmhl + +// Default build: Chroma-based assembly syntax highlighting from the curated +// lexer/style set. The `lite` build (asmhl_lite.go) swaps in a small +// theme-driven token highlighter and drops Chroma entirely. + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + + "github.com/rabarbra/exex/internal/arch" + "github.com/rabarbra/exex/internal/chromalexers" + "github.com/rabarbra/exex/internal/chromastyles" + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/syntax" +) + +// lexers caches the Chroma asm lexer per architecture. Keying by arch (rather +// than a single global) means opening a different-arch binary in the same process +// — e.g. a library via the Libs view's "open as primary" — gets the right lexer +// instead of reusing the first file's. +var lexers = map[arch.Arch]chroma.Lexer{} + +// lexerFor returns the asm lexer for a, building and caching it (even when nil) +// on first use. +func lexerFor(a arch.Arch) chroma.Lexer { + if l, ok := lexers[a]; ok { + return l + } + l := newLexer(a) + lexers[a] = l + return l +} + +func newLexer(a arch.Arch) chroma.Lexer { + names := []string{"ArmAsm", "GAS", "asm", "NASM"} + switch a { + case disasm.ArchX86, disasm.ArchAMD64, disasm.ArchRISCV64: + names = append([]string{"GAS"}, names...) + case disasm.ArchARM64: + names = append([]string{"ArmAsm"}, names...) + } + for _, name := range names { + if lexer := chromalexers.Get(name); lexer != nil { + return chroma.Coalesce(lexer) + } + } + return nil +} + +type chromaHighlighter struct { + st Styles + + // styled is false when the theme has no bundled Chroma style, or the + // architecture has no asm lexer: every line then falls back to renderByClass. + styled bool + lexer chroma.Lexer + style *chroma.Style + + // tokenStyles memoises Chroma token-type → lipgloss style. A screenful of + // instructions has thousands of tokens but a handful of distinct types. + tokenStyles map[chroma.TokenType]lipgloss.Style + fallbackFG string +} + +// newHighlighter resolves the theme's Chroma style and the architecture's asm +// lexer once. A highlighter is rebuilt (not mutated) when the theme changes, so +// its caches can never be stale. +func newHighlighter(themeName, fallbackFG string, a arch.Arch, st Styles) Highlighter { + h := &chromaHighlighter{st: st, fallbackFG: fallbackFG, tokenStyles: map[chroma.TokenType]lipgloss.Style{}} + style, ok := chromastyles.Lookup(themeName) + if !ok || style == nil { + return h // no bundled style: class colours only + } + lexer := lexerFor(a) + if lexer == nil { + return h // no asm lexer for this architecture + } + h.style, h.lexer, h.styled = style, lexer, true + return h +} + +func (h *chromaHighlighter) Render(text string, class disasm.InstClass, spans []Span) string { + if !h.styled { + return renderByClass(text, class, spans, h.st) + } + tokens, err := chroma.Tokenise(h.lexer, nil, text) + if err != nil { + return renderByClass(text, class, spans, h.st) + } + pos := 0 + var b strings.Builder + for _, tok := range tokens { + if tok == chroma.EOF { + break + } + b.WriteString(h.renderToken(tok, pos, spans)) + pos += len(tok.Value) + } + return b.String() +} + +// renderToken draws one Chroma token, splitting it wherever a link span overlaps +// so the address literal keeps its link colour. +func (h *chromaHighlighter) renderToken(tok chroma.Token, pos int, spans []Span) string { + st := h.tokenStyle(tok.Type) + from := 0 + var b strings.Builder + for _, span := range spans { + lo := max(span.Start, pos) + hi := min(span.End, pos+len(tok.Value)) + if hi <= lo { + continue + } + if rel := lo - pos; rel > from { + b.WriteString(st.Render(tok.Value[from:rel])) + } + b.WriteString(span.Style.Render(tok.Value[lo-pos : hi-pos])) + from = hi - pos + } + if from < len(tok.Value) { + b.WriteString(st.Render(tok.Value[from:])) + } + return b.String() +} + +func (h *chromaHighlighter) tokenStyle(tt chroma.TokenType) lipgloss.Style { + if st, ok := h.tokenStyles[tt]; ok { + return st + } + st := syntax.StyleEntryToLipgloss(h.style.Get(tt), h.fallbackFG) + h.tokenStyles[tt] = st + return st +} diff --git a/internal/ui/asmhl/asmhl_chroma_test.go b/internal/ui/asmhl/asmhl_chroma_test.go new file mode 100644 index 0000000..f9d82c5 --- /dev/null +++ b/internal/ui/asmhl/asmhl_chroma_test.go @@ -0,0 +1,65 @@ +//go:build !lite + +package asmhl + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/disasm" +) + +func TestAsmLexerForArch(t *testing.T) { + for _, tt := range []struct { + arch disasm.Arch + want string + }{ + {disasm.ArchX86, "GAS"}, + {disasm.ArchAMD64, "GAS"}, + {disasm.ArchRISCV64, "GAS"}, + {disasm.ArchARM64, "ArmAsm"}, + } { + l := newLexer(tt.arch) + if l == nil { + t.Fatalf("%v: no asm lexer", tt.arch) + } + if got := l.Config().Name; got != tt.want { + t.Fatalf("%v: asm lexer = %q, want %q", tt.arch, got, tt.want) + } + } +} + +// TestUnbundledThemeFallsBackToClassColours: a theme with no bundled Chroma style +// must still colour by instruction class rather than rendering plain text. +func TestUnbundledThemeFallsBackToClassColours(t *testing.T) { + class := lipgloss.NewStyle().Bold(true) + st := Styles{Class: func(disasm.InstClass) lipgloss.Style { return class }} + + h := New("definitely-not-a-style", "", disasm.ArchAMD64, st) + got := h.Render("mov %rsp,%rbp", disasm.ClassOther, nil) + if want := class.Render("mov %rsp,%rbp"); got != want { + t.Errorf("fallback = %q, want the class style applied to the whole line", got) + } +} + +// TestBundledThemeTokenisesPerToken: with a real style the line is split into +// tokens, so the output is not one uniform style run. +func TestBundledThemeTokenisesPerToken(t *testing.T) { + class := lipgloss.NewStyle().Bold(true) + st := Styles{Class: func(disasm.InstClass) lipgloss.Style { return class }} + + h := New("nord", "#d8dee9", disasm.ArchAMD64, st) + got := h.Render("mov %rsp,%rbp", disasm.ClassOther, nil) + if plain := ansi.Strip(got); plain != "mov %rsp,%rbp" { + t.Fatalf("plain text = %q", plain) + } + if got == class.Render("mov %rsp,%rbp") { + t.Error("a bundled theme rendered the whole line in the class style") + } + if !strings.Contains(got, "\x1b[") { + t.Error("no colour applied") + } +} diff --git a/internal/ui/disasm_syntax_lite.go b/internal/ui/asmhl/asmhl_lite.go similarity index 50% rename from internal/ui/disasm_syntax_lite.go rename to internal/ui/asmhl/asmhl_lite.go index 2f59538..3c1da88 100644 --- a/internal/ui/disasm_syntax_lite.go +++ b/internal/ui/asmhl/asmhl_lite.go @@ -1,6 +1,6 @@ //go:build lite -package ui +package asmhl // Lite build: a small, theme-driven assembly highlighter that replaces Chroma. // It colours the mnemonic by instruction class, followable mapped addresses by @@ -11,24 +11,32 @@ package ui import ( "strings" + "github.com/rabarbra/exex/internal/arch" "github.com/rabarbra/exex/internal/disasm" ) -// asmOperandKeywords are operand-position size/scope specifiers that read better +// operandKeywords are operand-position size/scope specifiers that read better // left uncoloured than tinted as registers. -var asmOperandKeywords = map[string]bool{ +var operandKeywords = map[string]bool{ "ptr": true, "byte": true, "word": true, "dword": true, "qword": true, "tword": true, "oword": true, "xmmword": true, "ymmword": true, "zmmword": true, "near": true, "far": true, "short": true, } -func (m *Model) renderInstTextStyled(text string, class disasm.InstClass, instAddr uint64) string { - spans := m.disasmAddrSpans(text, instAddr) +type liteHighlighter struct{ st Styles } + +// newHighlighter ignores the theme name, fallback colour and architecture: the +// lite build has no Chroma style or lexer to select. +func newHighlighter(_, _ string, _ arch.Arch, st Styles) Highlighter { + return liteHighlighter{st: st} +} + +func (h liteHighlighter) Render(text string, class disasm.InstClass, spans []Span) string { + st := h.st var b strings.Builder n := len(text) - // Mnemonic: the leading non-space run, coloured by instruction class or by - // the built-in lite category when this is otherwise a plain instruction. + // Mnemonic: the leading non-space run, coloured by instruction class. i := 0 for i < n && (text[i] == ' ' || text[i] == '\t') { b.WriteByte(text[i]) @@ -39,14 +47,13 @@ func (m *Model) renderInstTextStyled(text string, class disasm.InstClass, instAd i++ } if mnStart < i { - mnemonic := text[mnStart:i] - b.WriteString(m.theme.styleForClass(class).Render(mnemonic)) + b.WriteString(st.Class(class).Render(text[mnStart:i])) } for i < n { - if sp, ok := disasmSpanAt(spans, i); ok { - b.WriteString(sp.style.Render(text[i:sp.end])) - i = sp.end + if sp, ok := spanAt(spans, i); ok { + b.WriteString(sp.Style.Render(text[i:sp.End])) + i = sp.End continue } c := text[i] @@ -56,64 +63,54 @@ func (m *Model) renderInstTextStyled(text string, class disasm.InstClass, instAd for j < n && (text[j] == ' ' || text[j] == '\t') { j++ } - b.WriteString(m.theme.whiteStyle.Render(text[i:j])) + b.WriteString(st.Plain.Render(text[i:j])) i = j case c == '%': // AT&T register (%rax, %xmm0) j := i + 1 - for j < n && isAsmIdentChar(text[j]) { + for j < n && isIdentChar(text[j]) { j++ } - b.WriteString(m.theme.asmRegisterStyle.Render(text[i:j])) + b.WriteString(st.Register.Render(text[i:j])) i = j case c == '$' || c == '#': // immediate prefix (AT&T $, ARM #) j := i + 1 - for j < n && isAsmNumChar(text[j]) { + for j < n && isNumChar(text[j]) { j++ } - b.WriteString(m.theme.asmNumberStyle.Render(text[i:j])) + b.WriteString(st.Number.Render(text[i:j])) i = j case c >= '0' && c <= '9': j := i + 1 - for j < n && isAsmNumChar(text[j]) { + for j < n && isNumChar(text[j]) { j++ } - b.WriteString(m.theme.asmNumberStyle.Render(text[i:j])) + b.WriteString(st.Number.Render(text[i:j])) i = j - case isAsmIdentStart(c): + case isIdentStart(c): j := i + 1 - for j < n && isAsmIdentChar(text[j]) { + for j < n && isIdentChar(text[j]) { j++ } tok := text[i:j] - if asmOperandKeywords[strings.ToLower(tok)] { - b.WriteString(m.theme.whiteStyle.Render(tok)) + if operandKeywords[strings.ToLower(tok)] { + b.WriteString(st.Plain.Render(tok)) } else { - b.WriteString(m.theme.asmRegisterStyle.Render(tok)) + b.WriteString(st.Register.Render(tok)) } i = j default: // punctuation: [], (), commas, +, -, *, : … - b.WriteString(m.theme.whiteStyle.Render(text[i : i+1])) + b.WriteString(st.Plain.Render(text[i : i+1])) i++ } } return b.String() } -// disasmSpanAt returns the address span covering byte index i, if any. -func disasmSpanAt(spans []disasmAddrSpan, i int) (disasmAddrSpan, bool) { - for _, s := range spans { - if i >= s.start && i < s.end { - return s, true - } - } - return disasmAddrSpan{}, false -} - -func isAsmIdentStart(c byte) bool { return c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') } -func isAsmIdentChar(c byte) bool { - return isAsmIdentStart(c) || (c >= '0' && c <= '9') || c == '.' +func isIdentStart(c byte) bool { return c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') } +func isIdentChar(c byte) bool { + return isIdentStart(c) || (c >= '0' && c <= '9') || c == '.' } -func isAsmNumChar(c byte) bool { +func isNumChar(c byte) bool { return (c >= '0' && c <= '9') || c == 'x' || c == 'X' || c == '.' || c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'f') } diff --git a/internal/ui/asmhl/asmhl_lite_test.go b/internal/ui/asmhl/asmhl_lite_test.go new file mode 100644 index 0000000..15f0ded --- /dev/null +++ b/internal/ui/asmhl/asmhl_lite_test.go @@ -0,0 +1,61 @@ +//go:build lite + +package asmhl + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/disasm" +) + +func liteStyles() (Styles, lipgloss.Style, lipgloss.Style, lipgloss.Style, lipgloss.Style) { + class := lipgloss.NewStyle().Foreground(lipgloss.Color("#010203")) + plain := lipgloss.NewStyle().Foreground(lipgloss.Color("#040506")) + reg := lipgloss.NewStyle().Foreground(lipgloss.Color("#070809")) + num := lipgloss.NewStyle().Foreground(lipgloss.Color("#0a0b0c")) + return Styles{ + Class: func(disasm.InstClass) lipgloss.Style { return class }, + Plain: plain, + Register: reg, + Number: num, + }, class, plain, reg, num +} + +// TestLiteMnemonicUsesTheClassStyle: the mnemonic is coloured by instruction +// class, the operands by their token kind. +func TestLiteMnemonicUsesTheClassStyle(t *testing.T) { + st, class, _, reg, num := liteStyles() + h := New("", "", disasm.ArchAMD64, st) + + got := h.Render("mov %rsp,%rbp", disasm.Classify("mov %rsp,%rbp"), nil) + if plain := ansi.Strip(got); plain != "mov %rsp,%rbp" { + t.Fatalf("plain text = %q", plain) + } + if !strings.Contains(got, class.Render("mov")) { + t.Errorf("mnemonic not styled by class: %q", got) + } + if !strings.Contains(got, reg.Render("%rsp")) { + t.Errorf("register not styled: %q", got) + } + + got = h.Render("add $1,%eax", disasm.Classify("add $1,%eax"), nil) + if !strings.Contains(got, num.Render("$1")) { + t.Errorf("immediate not styled as a number: %q", got) + } +} + +// TestLiteOperandKeywordsAreNotRegisters: "qword ptr" reads better plain. +func TestLiteOperandKeywordsAreNotRegisters(t *testing.T) { + st, _, plain, _, _ := liteStyles() + h := New("", "", disasm.ArchAMD64, st) + got := h.Render("mov qword ptr [rax], rbx", disasm.ClassOther, nil) + for _, kw := range []string{"qword", "ptr"} { + if !strings.Contains(got, plain.Render(kw)) { + t.Errorf("%q was not rendered plain: %q", kw, got) + } + } +} diff --git a/internal/ui/asmhl/span_test.go b/internal/ui/asmhl/span_test.go new file mode 100644 index 0000000..06bf959 --- /dev/null +++ b/internal/ui/asmhl/span_test.go @@ -0,0 +1,83 @@ +package asmhl + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/disasm" +) + +// These run under both build tags: whichever highlighter is compiled in must +// honour the link spans, because that is the one behaviour the shell depends on. + +func spanStyles() (Styles, lipgloss.Style) { + link := lipgloss.NewStyle().Underline(true) + st := Styles{ + Class: func(disasm.InstClass) lipgloss.Style { return lipgloss.NewStyle().Bold(true) }, + Plain: lipgloss.NewStyle(), + Register: lipgloss.NewStyle(), + Number: lipgloss.NewStyle(), + } + return st, link +} + +// TestSpanIsDrawnInItsLinkStyle: a followable address literal keeps its link +// colour, whatever the token colours around it are. +func TestSpanIsDrawnInItsLinkStyle(t *testing.T) { + st, link := spanStyles() + h := New("nord", "#d8dee9", disasm.ArchAMD64, st) + + const text = "call 0x401020" + start := strings.Index(text, "0x401020") + got := h.Render(text, disasm.Classify(text), []Span{{Start: start, End: len(text), Style: link}}) + + if plain := ansi.Strip(got); plain != text { + t.Fatalf("plain text = %q, want %q", plain, text) + } + if !strings.Contains(got, link.Render("0x401020")) { + t.Errorf("the address span was not drawn in its link style:\n%q", got) + } +} + +// TestSpanInsideAToken: Chroma may tokenise "[0x1000]" as one token, so a span +// covering part of it has to split that token. +func TestSpanInsideAToken(t *testing.T) { + st, link := spanStyles() + h := New("nord", "#d8dee9", disasm.ArchAMD64, st) + + const text = "mov [0x401020],%rax" + start := strings.Index(text, "0x401020") + got := h.Render(text, disasm.Classify(text), []Span{{Start: start, End: start + len("0x401020"), Style: link}}) + + if plain := ansi.Strip(got); plain != text { + t.Fatalf("plain text = %q, want %q", plain, text) + } + if !strings.Contains(got, link.Render("0x401020")) { + t.Errorf("a span inside a token was not honoured:\n%q", got) + } +} + +// TestNoSpansLeavesTextIntact guards the common case. +func TestNoSpansLeavesTextIntact(t *testing.T) { + st, _ := spanStyles() + h := New("nord", "#d8dee9", disasm.ArchAMD64, st) + const text = "nop" + if plain := ansi.Strip(h.Render(text, disasm.Classify(text), nil)); plain != text { + t.Errorf("plain text = %q, want %q", plain, text) + } +} + +func TestSpanAt(t *testing.T) { + spans := []Span{{Start: 2, End: 5}, {Start: 8, End: 10}} + for _, tc := range []struct { + i int + want bool + }{{0, false}, {2, true}, {4, true}, {5, false}, {8, true}, {9, true}, {10, false}, {99, false}} { + if _, ok := spanAt(spans, tc.i); ok != tc.want { + t.Errorf("spanAt(%d) = %v, want %v", tc.i, ok, tc.want) + } + } +} diff --git a/internal/ui/async_test.go b/internal/ui/async_test.go index b104d36..5257cda 100644 --- a/internal/ui/async_test.go +++ b/internal/ui/async_test.go @@ -6,6 +6,9 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/disasm" "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/explorer" + syscallsmodal "github.com/rabarbra/exex/internal/ui/modals/syscalls" + xrefmodal "github.com/rabarbra/exex/internal/ui/modals/xref" ) func TestAsyncMessagesIgnoreStaleFile(t *testing.T) { @@ -19,10 +22,10 @@ func TestAsyncMessagesIgnoreStaleFile(t *testing.T) { t.Fatalf("stale demangle mutated current file: %q", got) } - m.disasmDecoding = true - m.disasmPendingAddr = 0x1000 - if _, _ = m.handleDisasmReady(disasmReadyMsg{file: oldFile, addr: 0x1000, insts: []disasm.Inst{{Addr: 0x1000}}}); !m.disasmDecoding || len(m.disasmInst) != 0 { - t.Fatalf("stale disasm ready was applied: decoding=%v insts=%d", m.disasmDecoding, len(m.disasmInst)) + m.dasm.Decoding = true + m.dasm.PendingAddr = 0x1000 + if _, _ = m.handleDisasmReady(disasmReadyMsg{file: oldFile, addr: 0x1000, span: explorer.Span{Insts: []disasm.Inst{{Addr: 0x1000}}}}); !m.dasm.Decoding || len(m.dasm.Inst) != 0 { + t.Fatalf("stale disasm ready was applied: decoding=%v insts=%d", m.dasm.Decoding, len(m.dasm.Inst)) } m.searchRunning = true @@ -33,26 +36,47 @@ func TestAsyncMessagesIgnoreStaleFile(t *testing.T) { m.xrefRunning = true m.xrefSeq = 1 - if _, _ = m.handleXrefDone(xrefDoneMsg{file: oldFile, seq: 1, target: 0x1000, hits: []xrefHit{{addr: 0x1000}}}); !m.xrefRunning || m.xrefActive { - t.Fatalf("stale xref result was applied: running=%v active=%v", m.xrefRunning, m.xrefActive) + if _, _ = m.handleXrefDone(xrefDoneMsg{file: oldFile, seq: 1, target: 0x1000, hits: []xrefmodal.Hit{{Addr: 0x1000}}}); !m.xrefRunning || m.xref.Active() { + t.Fatalf("stale xref result was applied: running=%v active=%v", m.xrefRunning, m.xref.Active()) } m.syscallRunning = true m.syscallSeq = 1 - if _, _ = m.handleSyscallDone(syscallDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}}); !m.syscallRunning || m.syscallActive { - t.Fatalf("stale syscall result was applied: running=%v active=%v", m.syscallRunning, m.syscallActive) + if _, _ = m.handleSyscallDone(syscallDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}}); !m.syscallRunning || m.syscalls.Active() { + t.Fatalf("stale syscall result was applied: running=%v active=%v", m.syscallRunning, m.syscalls.Active()) } m.cpufeatRunning = true m.cpufeatSeq = 1 - if _, _ = m.handleCPUFeatDone(cpufeatDoneMsg{file: oldFile, seq: 1, set: dump.CPUFeatureSet{Counts: map[string]int{"AVX": 1}}}); !m.cpufeatRunning || m.cpufeatDone { - t.Fatalf("stale CPU-feature result was applied: running=%v done=%v", m.cpufeatRunning, m.cpufeatDone) + if _, _ = m.handleCPUFeatDone(cpufeatDoneMsg{file: oldFile, seq: 1, set: dump.CPUFeatureSet{Counts: map[string]int{"AVX": 1}}}); !m.cpufeatRunning || m.cpufeat.Scanned() { + t.Fatalf("stale CPU-feature result was applied: running=%v scanned=%v", m.cpufeatRunning, m.cpufeat.Scanned()) } m.syscallFullRunning = true m.syscallFullSeq = 1 - if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); !m.syscallFullRunning || m.syscallFullDone { - t.Fatalf("stale full syscall result was applied: running=%v done=%v", m.syscallFullRunning, m.syscallFullDone) + if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); !m.syscallFullRunning || m.syscalls.FullDone() { + t.Fatalf("stale full syscall result was applied: running=%v done=%v", m.syscallFullRunning, m.syscalls.FullDone()) + } +} + +// TestSyscallEmptyFallsBackToFullScope: a direct scan that finds nothing (e.g. a +// macOS executable, whose syscalls all live in libsystem_kernel via the cache) +// must still open the modal — in full (+libs) scope, with the transitive scan +// kicked off — instead of a bare "no syscalls found". +func TestSyscallEmptyFallsBackToFullScope(t *testing.T) { + f := &binfile.File{} + m := &Model{file: f} + m.syscallRunning = true + m.syscallSeq = 3 + _, cmd := m.handleSyscallDone(syscallDoneMsg{file: f, seq: 3, sites: nil}) + if !m.syscalls.Active() { + t.Fatal("modal did not open on an empty direct scan") + } + if m.syscalls.Scope() != syscallsmodal.ScopeFull { + t.Fatalf("scope = %d, want full (%d)", m.syscalls.Scope(), syscallsmodal.ScopeFull) + } + if !m.syscallFullRunning || cmd == nil { + t.Fatalf("full (+libs) scan was not started: running=%v cmd=%v", m.syscallFullRunning, cmd != nil) } } @@ -64,7 +88,7 @@ func TestCancelSyscallFullScanClosesChannelAndIgnoresLateResult(t *testing.T) { m.syscallFullSeq = 7 m.syscallFullCancel = done - m.cancelSyscallFullScan() + m.CancelFullScan() if m.syscallFullRunning { t.Fatal("full syscall scan still marked running after cancel") } @@ -80,7 +104,7 @@ func TestCancelSyscallFullScanClosesChannelAndIgnoresLateResult(t *testing.T) { t.Fatal("full syscall cancel channel was not closed") } - if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: f, seq: 7, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); m.syscallFullDone || len(m.syscallFull) != 0 { - t.Fatalf("late cancelled full syscall result was applied: done=%v sites=%d", m.syscallFullDone, len(m.syscallFull)) + if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: f, seq: 7, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); m.syscalls.FullDone() || len(m.syscalls.FullSites()) != 0 { + t.Fatalf("late cancelled full syscall result was applied: done=%v sites=%d", m.syscalls.FullDone(), len(m.syscalls.FullSites())) } } diff --git a/internal/ui/avail.go b/internal/ui/avail.go deleted file mode 100644 index 38c0784..0000000 --- a/internal/ui/avail.go +++ /dev/null @@ -1,58 +0,0 @@ -package ui - -import "github.com/rabarbra/exex/internal/explorer" - -// availFilter is the availability lens applied to the Sources and Libs lists. -type availFilter uint8 - -const ( - availAll availFilter = iota // show everything - availPresent // sources: file on disk; libs: openable on disk - availMissing // sources: not found on disk - availCache // libs: served from the dyld shared cache -) - -// availKind classifies one library's on-disk availability. -type availKind uint8 - -const ( - libOnDisk availKind = iota // resolves to a real file we can open - libInCache // a system lib served from the dyld shared cache - libMissing // neither — can't be located -) - -// libAvail classifies a library path, caching the (filesystem-touching) result. -func (m *Model) libAvail(lib string) availKind { - if m.libsAvailKind == nil { - m.libsAvailKind = map[string]availKind{} - } - if k, ok := m.libsAvailKind[lib]; ok { - return k - } - var k availKind - switch { - case func() bool { _, ok := explorer.ResolveLibPath(lib, m.file.Path, m.file.Info, nil); return ok }(): - k = libOnDisk - case explorer.IsDyldSharedCacheLib(lib): - k = libInCache - default: - k = libMissing - } - m.libsAvailKind[lib] = k - return k -} - -// availLabel is the short status-line label for an availability filter, given the -// per-view name for the "present" state. -func availLabel(f availFilter) string { - switch f { - case availPresent: - return "present" - case availMissing: - return "missing" - case availCache: - return "cache" - default: - return "all" - } -} diff --git a/internal/ui/avail_test.go b/internal/ui/avail_test.go index 14684e8..191bd1a 100644 --- a/internal/ui/avail_test.go +++ b/internal/ui/avail_test.go @@ -6,30 +6,10 @@ import ( "testing" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/view" + "github.com/rabarbra/exex/internal/ui/views/sources" ) -func TestLibAvailClassification(t *testing.T) { - dir := t.TempDir() - real := filepath.Join(dir, "libreal.dylib") - if err := os.WriteFile(real, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - m := &Model{file: &binfile.File{Path: filepath.Join(dir, "bin"), Info: &binfile.Info{}}} - cases := []struct { - lib string - want availKind - }{ - {real, libOnDisk}, - {"/usr/lib/libSystem.B.dylib", libInCache}, // dyld shared cache - {"/no/such/path/libx.dylib", libMissing}, - } - for _, c := range cases { - if got := m.libAvail(c.lib); got != c.want { - t.Errorf("libAvail(%q) = %d, want %d", c.lib, got, c.want) - } - } -} - func TestSourcesAvailFilter(t *testing.T) { dir := t.TempDir() present := filepath.Join(dir, "here.c") @@ -38,24 +18,24 @@ func TestSourcesAvailFilter(t *testing.T) { } missing := filepath.Join(dir, "gone.c") m := &Model{ - file: binfile.NewRawFile(nil), - sourcesState: sourcesState{sourcesFiles: []string{present, missing}}, + file: binfile.NewRawFile(nil), + sources: sources.State{Files: []string{present, missing}}, } m.file.Path = filepath.Join(dir, "bin") - m.sourcesFilter = newPromptInput("", "/ ") + m.sources.Filter = newPromptInput("", "/ ") - count := func(a availFilter) int { - m.sourcesAvail = a - m.recomputeSourceFiles() - return len(m.sourcesFiltered) + count := func(a view.AvailFilter) int { + m.sources.Avail = a + m.sources.Recompute(m.viewContext()) + return len(m.sources.Filtered) } - if got := count(availAll); got != 2 { + if got := count(view.AvailAll); got != 2 { t.Fatalf("all = %d, want 2", got) } - if got := count(availPresent); got != 1 { + if got := count(view.AvailPresent); got != 1 { t.Fatalf("present = %d, want 1", got) } - if got := count(availMissing); got != 1 { + if got := count(view.AvailMissing); got != 1 { t.Fatalf("missing = %d, want 1", got) } } diff --git a/internal/ui/byteopen.go b/internal/ui/byteopen.go new file mode 100644 index 0000000..443adf0 --- /dev/null +++ b/internal/ui/byteopen.go @@ -0,0 +1,25 @@ +package ui + +import "github.com/rabarbra/exex/internal/binfile" + +// Shell-side byte-view routing shared across views. The Hex/Raw rendering and +// cursor mechanics live in internal/ui/views/hexraw; these helpers only perform +// cross-view opens and offset/address lookup that require the shell model. + +// openHexAt switches to the virtual-address Hex view and seeks addr. +func (m *Model) openHexAt(addr uint64) { + if m.byteViews.PositionHexAt(m.viewContextPtr(), m, addr) { + m.setMode(modeHex) + } +} + +// openRawAt switches to the raw file view and seeks file offset off. +func (m *Model) openRawAt(off uint64) { + m.byteViews.PositionRawAt(m.viewContextPtr(), off) + m.setMode(modeRaw) +} + +// sectionAtOffset resolves the section whose file bytes cover off. +func (m *Model) sectionAtOffset(off uint64) *binfile.Section { + return m.byteViews.SectionAtOffset(m.file, off) +} diff --git a/internal/ui/bytesource.go b/internal/ui/bytesource.go index d9675bf..b29d16e 100644 --- a/internal/ui/bytesource.go +++ b/internal/ui/bytesource.go @@ -1,24 +1,6 @@ package ui -import "github.com/rabarbra/exex/internal/binfile" +import "github.com/rabarbra/exex/internal/ui/views/hexraw" -// byteSource is the random-access byte stream the hex/raw views read from. It -// abstracts over the raw file bytes (a flat slice) and the virtual-address Image -// (region-backed, zero-copy slices into the file). Bounded reads go through Bytes -// (zero-copy within one region); whole-image scans iterate Runs so bytes.Index -// runs on the real bytes at full speed, region by region. -type byteSource interface { - Len() int - At(i int) byte - Bytes(start, end int) []byte - Runs() []binfile.Run -} - -// rawBytes adapts a flat byte slice (the raw-file view) to byteSource. It is a -// single run spanning the whole slice. -type rawBytes []byte - -func (r rawBytes) Len() int { return len(r) } -func (r rawBytes) At(i int) byte { return r[i] } -func (r rawBytes) Bytes(start, end int) []byte { return r[start:end] } -func (r rawBytes) Runs() []binfile.Run { return []binfile.Run{{Off: 0, B: r}} } +type byteSource = hexraw.ByteSource +type rawBytes = hexraw.RawBytes diff --git a/internal/ui/chrome.go b/internal/ui/chrome.go index 37b539c..9195f18 100644 --- a/internal/ui/chrome.go +++ b/internal/ui/chrome.go @@ -1,13 +1,13 @@ package ui import ( - "fmt" "image/color" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" ) // View renders the screen. @@ -21,50 +21,18 @@ func (m *Model) View() tea.View { if !m.viewDirty && m.viewCache != "" { return m.screenView(m.viewCache) } - parts := []string{m.renderTabs()} - body := "" - switch m.mode { - case modeInfo: - body = m.renderInfo() - case modeSections: - body = m.renderSections() - case modeSymbols: - body = m.renderSymbols() - case modeDisasm: - body = m.renderDisasm() - case modeHex: - body = m.renderHex() - case modeRaw: - body = m.renderRaw() - case modeStrings: - body = m.renderStrings() - case modeSources: - body = m.renderSources() - case modeLibs: - body = m.renderLibs() - case modeRelocs: - body = m.renderRelocs() + // The panel background fills the whole frame, not just the body: the tab strip + // and the footer belong to the panel too. renderViewBackground re-applies the + // fill after every style reset, so a tab's own colours and the status badge + // still win over it, and it is a no-op when behavior.background is off. + parts := []string{ + m.theme.renderViewBackground(m.renderTabs(), m.width), + m.theme.renderViewBackground(m.current().body(), m.width), + m.theme.renderViewBackground(m.renderFooter(), m.width), } - body = m.theme.renderViewBackground(body, m.width) - parts = append(parts, body, m.renderFooter()) out := lipgloss.JoinVertical(lipgloss.Left, parts...) - switch { - case m.headerActive: - out = m.overlayCenter(out, m.renderHeaderModal()) - case m.helpActive: - out = m.overlayCenter(out, m.renderHelpModal()) - case m.settingsActive: - out = m.overlayCenter(out, m.renderSettingsModal()) - case m.gotoActive: - out = m.overlayCenter(out, m.renderGotoModal()) - case m.searchActive: - out = m.overlayCenter(out, m.renderSearchModal()) - case m.xrefActive: - out = m.overlayCenter(out, m.renderXrefModal()) - case m.syscallActive: - out = m.overlayCenter(out, m.renderSyscallModal()) - case m.cpufeatActive: - out = m.overlayCenter(out, m.renderCPUFeatModal()) + if modal := m.renderActiveModal(); modal != "" { + out = m.overlayCenter(out, modal) } m.viewCache = out m.viewDirty = false @@ -80,367 +48,20 @@ func (m *Model) screenView(out string) tea.View { return v } -// renderHelpModal lists the keybindings, grouped by scope, in two columns. The -// key column is padded by display width (so multibyte arrows align) and the two -// columns are laid out side by side to keep the modal compact. -// helpEntry is one line of a help column: a section header, a key/description -// row, or a blank spacer. -type helpEntry struct { - head string // section title (uppercased + ruled) when non-empty - text string // a pre-rendered key+desc row; "" with no head = blank line -} - -func (m *Model) renderHelpModal() string { - const keyW = 16 - row := func(keys, desc string) helpEntry { - return helpEntry{text: m.theme.helpKeyStyle.Render(padVisual(keys, keyW)) + " " + m.theme.helpDescStyle.Render(desc)} - } - head := func(s string) helpEntry { return helpEntry{head: s} } - blank := helpEntry{} - - left := []helpEntry{ - head("Global"), - row("1–9 · 0", "switch view (0 = relocations)"), - row("g", "jump to anything (symbol/section/string/lib/addr · ⇥ scope)"), - row(",", "settings (theme, wrap, …)"), - row("?", "this help"), - row("w", "toggle long-line wrap"), - row("d/h/m", "go to addr in disasm / hex / raw"), - row("⇧a/⇧s/⇧l", "copy address / name / line"), - row("t / ⇥", "switch view"), - row("/ n/N", "search · next/prev"), - row("^O", "back (return from an opened dependency)"), - row("⇧F", "CPU features required (SSE/AVX/NEON · baseline)"), - row("⇧H", "raw file header (ELF e_* / Mach-O load cmds / PE)"), - row("q / ^C", "quit"), - row("↵ Enter", "open / jump"), - blank, - head("Lists (all views)"), - row("/", "filter / search"), - row("↑/↓", "move line"), - row("s/r", "sort · reverse"), - row("PgUp/PgDn [ ]", "page ("+ctrlKeys("↑", "↓")+")"), - row("Home/End ^A/^E", "begin/end"), - row("Esc", "clear filters"), - blank, - head("Tree actions"), - row("t / ⇥", "toggle namespace tree / flat table"), - row("←/→", "tree: collapse / expand group"), - row("↵ · +/−", "tree: expand/collapse all below · all"), - blank, - head("Info"), - row("t / ⇥", "fat-Mach-O arch slice · static-lib members list"), - row("↵ Enter", "open entry point · open selected member"), - blank, - head("Sections"), - row(ctrlKeys("t", "f"), "filter by type / flags"), - row("t / ⇥", "cycle sections / segments / header"), - blank, - head("Symbols"), - row(ctrlKeys("t", "b", "s"), "filter by type / bind / scope"), - row("e / .", "collapse (…)/<…> to ... · all / current"), - } - right := []helpEntry{ - head("Disassembly"), - row("↵ Enter", "follow address"), - row("[ ]", "previous / next symbol"), - row("←/→", "history back / forward"), - row("x", "find references (xrefs)"), - row("y", "list system calls"), - row("a", "disassemble all sections / exec-only (object files, data)"), - row("⇧a/⇧s/⇧c", "copy addr / symbol / function asm"), - row("Tab", "show / hide right pane"), - row("⇧Tab", "swap source / disasm"), - row("⇧↑/⇧↓", "scroll right pane"), - row("", "modals (xrefs / syscalls): / filter · s/r sort"), - blank, - head("Hex / Raw"), - row("[ ]", "prev / next section"), - row("⇧[ ⇧]", "prev / next nonzero"), - row("t / ⇥", "trailing column: ascii ↔ numeric"), - row("⇧t", "cycle interpretation (i8…i64/u…/f32/f64)"), - row("i", "data inspector"), - row("⇧a/⇧s/⇧p", "copy address / symbol / pointer"), - row("↵ Enter", "follow pointer at cursor"), - blank, - head("Sources"), - row("[ ]", "prev / next mapped line"), - row(ctrlKeys("p"), "filter: all / present / missing"), - row("t / ⇥", "toggle directory tree / flat list"), - row("↵ Enter / o", "open in disasm source-first view"), - blank, - head("Libraries / Relocations"), - row("8 / 0", "libraries view / relocations view"), - row("o", "(libs) open as primary"), - row(ctrlKeys("p"), "(libs) filter all/on-disk/cache"), - row("t / ⇥", "(libs) flat ↔ tree"), - row("↵", "libs: imported symbols · relocs: go to patched addr"), - row("s/r "+ctrlKeys("t", "s"), "(relocs) sort/rev · type/section filter"), - blank, - head("Strings"), - row(ctrlKeys("s"), "filter by section"), - row(ctrlKeys("p"), "filter to paths only"), - row("t / ⇥", "table ↔ compact (· flow) layout"), - } - - leftLines := m.helpColumn(left) - rightLines := m.helpColumn(right) - lw, rw := lipgloss.Width(leftLines[0]), lipgloss.Width(rightLines[0]) - - // Two side-by-side columns when they fit the terminal; otherwise stack into a - // single column so the modal never overruns a narrow window. - var bodyRows []string - if lw+rw+6 <= m.width-6 { - div := m.theme.srcShadowStyle.Render("│") - n := max(len(leftLines), len(rightLines)) - for i := range n { - l, r := padVisual("", lw), padVisual("", rw) - if i < len(leftLines) { - l = leftLines[i] - } - if i < len(rightLines) { - r = rightLines[i] - } - bodyRows = append(bodyRows, l+" "+div+" "+r) - } - } else { - bodyRows = append(bodyRows, leftLines...) - bodyRows = append(bodyRows, padVisual("", lw)) - bodyRows = append(bodyRows, rightLines...) - } - - // Vertically window the body when it is taller than the screen, scrolled by - // m.helpScroll (the title, hint and modal chrome cost ~8 rows). - hint := "Mouse: wheel scrolls · over right pane scrolls it · click selects · double-click follows" - total := len(bodyRows) - maxRows := max(1, m.height-8) - if total > maxRows { - m.helpScroll = clamp(m.helpScroll, 0, total-maxRows) - bodyRows = bodyRows[m.helpScroll : m.helpScroll+maxRows] - hint = fmt.Sprintf("↑/↓ scroll · %d–%d of %d · Esc/any key closes", - m.helpScroll+1, m.helpScroll+maxRows, total) - } else { - m.helpScroll = 0 - } - - // Never let a row push the modal past the terminal (very narrow windows). - rowCap := max(1, m.width-6) - - var b strings.Builder - b.WriteString(m.theme.modalTitle("Keybindings")) - b.WriteString("\n\n") - for _, r := range bodyRows { - b.WriteString(fitANSIWidth(r, rowCap)) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(m.theme.modalHint(hint)) - return m.theme.modalStyle.Render(b.String()) -} - -// helpPageStep is how many rows PgUp/PgDn move the help overlay. -const helpPageStep = 8 - -// helpColumn renders a help column: rows padded to a common width, section -// headers shown uppercase with a dim rule to the column edge (matching the Info -// view), blanks as empty lines. -func (m *Model) helpColumn(entries []helpEntry) []string { - w := 0 - for _, e := range entries { - if e.head == "" { - if rw := ansi.StringWidth(e.text); rw > w { - w = rw - } - } - } - out := make([]string, len(entries)) - for i, e := range entries { - switch { - case e.head != "": - label := strings.ToUpper(e.head) + " " - line := m.theme.helpHeadStyle.Render(label) - if fill := w - lipgloss.Width(label); fill > 0 { - line += m.theme.srcShadowStyle.Render(strings.Repeat("─", fill)) - } - out[i] = padVisual(line, w) - default: - out[i] = padVisual(e.text, w) - } - } - return out -} - // Shared modal styling, so every popup (help, goto, search, settings, xrefs, // path picker) looks the same: a filled title bar, dim hint/footer lines, and a // common list width. func (t Theme) modalTitle(s string) string { return t.titleStyle.Render(" " + s + " ") } func (t Theme) modalHint(s string) string { return t.footerStyle.Padding(0).Render(s) } -func modalListWidth(termW int) int { return clamp(termW-8, 40, 120) } +func modalListWidth(termW int) int { return layout.Clamp(termW-8, 40, 120) } // overlayCenter draws a pre-rendered modal centred over bg. func (m *Model) overlayCenter(bg, modal string) string { mw := lipgloss.Width(modal) - modal = renderStyle(modal, mw, m.theme.tableRowStyle) + modal = layout.RenderStyle(modal, mw, m.theme.tableRowStyle) modal = m.theme.renderViewBackground(modal, mw) mh := lipgloss.Height(modal) - return overlay(bg, modal, (m.width-mw)/2, (m.height-mh)/2) -} - -func (m *Model) renderGotoModal() string { - var sb strings.Builder - rowW := modalListWidth(m.width) - sb.WriteString(m.theme.modalTitle("Jump to")) - sb.WriteString("\n") - sb.WriteString(fitANSIWidth(m.gotoScopeBar(), rowW)) - sb.WriteString("\n") - sb.WriteString(m.gotoInput.View()) - sb.WriteString("\n") - m.modalListRow = 4 // title + scope bar + input + blank → list at row 4 - if len(m.gotoResults) == 0 { - sb.WriteString("\n") - sb.WriteString(m.theme.modalHint("type to search · ⇥ scope · " + m.gotoEmptyHint())) - sb.WriteString("\n") - } else { - sb.WriteString("\n") - addrW := m.file.AddrHexWidth() - visible := clamp(m.height-11, 3, 40) - gotoTop := visualTop(m.gotoSel, m.gotoTop, len(m.gotoResults), visible, func(int) int { return 1 }) - m.gotoTop = gotoTop - end := min(gotoTop+visible, len(m.gotoResults)) - labelW := rowW - addrW - 9 - for i := gotoTop; i < end; i++ { - t := m.gotoResults[i] - loc := strings.Repeat(" ", 2+addrW) - if t.hasAddr || t.kind == gkAddr { - loc = m.theme.addrStyle.Render(fmt.Sprintf("0x%0*x", addrW, t.addr)) - } else if t.kind == gkLib { - loc = padVisual("", 2+addrW) - } - // Colour the kind tag and label by kind so mixed results (the All scope) - // are distinguishable at a glance. - label := m.gotoKindStyle(t).Render(truncateMiddle(t.label, labelW)) - line := fmt.Sprintf(" %s %s %s", - m.gotoTagStyle(t.kind).Render(t.kind.tag()), loc, label) - line = padRight(line, rowW) - if i == m.gotoSel { - line = m.theme.tableSelStyle.Render(ansi.Strip(line)) - } - sb.WriteString(line + "\n") - } - } - count := "" - if n := len(m.gotoResults); n > 0 { - count = fmt.Sprintf(" (%d/%d)", m.gotoSel+1, n) - } - sb.WriteString("\n") - sb.WriteString(m.theme.modalHint("↑/↓ select · ↵ jump · ⇥ scope · Esc cancel" + count)) - return m.theme.modalStyle.Render(sb.String()) -} - -// gotoScopeBar renders the scope selector with the active scope highlighted, plus -// the physical-address toggle when the binary has distinct LMAs. -func (m *Model) gotoScopeBar() string { - var b strings.Builder - for s := gotoScope(0); s < gsScopeCount; s++ { - if s > 0 { - b.WriteString(m.theme.srcShadowStyle.Render(" ")) - } - if s == m.gotoScope { - b.WriteString(m.theme.tableSelStyle.Render(" " + s.String() + " ")) - } else { - b.WriteString(m.theme.srcShadowStyle.Render(" " + s.String() + " ")) - } - } - if (m.gotoScope == gsAll || m.gotoScope == gsAddr) && m.file.HasPhysAddrs() { - tag := "virtual" - if m.gotoAddrPhys { - tag = m.theme.warnStyle.Render("physical") - } - b.WriteString(m.theme.modalHint(" addr: ") + tag + m.theme.modalHint(" (^p)")) - } - return b.String() -} - -// gotoTagStyle colours the kind badge with a distinct hue per kind. In the All -// scope only addr/sym/sec appear, and those three (blue/green/yellow) are clearly -// distinct; str/lib show only in their own scopes. -func (m *Model) gotoTagStyle(k gotoKind) lipgloss.Style { - switch k { - case gkSymbol: - return m.theme.infoStyle // green - case gkSection: - return m.theme.warnStyle // yellow - case gkString: - return m.theme.errorStyle // red - case gkLib: - return m.theme.srcShadowStyle // dim - default: - return m.theme.headerKey // addr — blue - } -} - -// gotoKindStyle colours a result by kind (symbols by their own kind/bind colour, -// like the Symbols view; other kinds by category). -func (m *Model) gotoKindStyle(t gotoTarget) lipgloss.Style { - switch t.kind { - case gkSymbol: - return m.theme.styleForSymbol(t.sym.Kind, t.sym.Bind) - case gkSection: - return m.theme.infoStyle - case gkString: - return m.theme.tableRowStyle - case gkLib: - return m.theme.symbolNameStyle - default: - return m.theme.headerKey // address - } -} - -// gotoEmptyHint names what the current scope searches. -func (m *Model) gotoEmptyHint() string { - switch m.gotoScope { - case gsAddr: - return "a hex/decimal address" - case gsStrings: - return "a printable string" - case gsLibs: - return "a linked library" - case gsSections: - return "a section name" - case gsSymbols: - return "a symbol name" - default: - return "a symbol, section or address" - } -} - -func (m *Model) renderSearchModal() string { - hint := "Search this view" - switch m.mode { - case modeDisasm: - hint = "Search instruction text / symbol" - case modeHex, modeRaw: - hint = "Search hex bytes (de ad be ef), \"text\", or 0x…" - case modeSources: - if m.srcSearchAll { - hint = "Search across all source files" - } else { - hint = "Search in this source file" - } - } - // Switch strip (content row searchSwitchLine) — clickable; geometry shared - // with handleSearchPopupClick via searchSwitches(). Each switch is a dim name - // plus the current value in an accent pill. - var segs []string - for _, sw := range m.searchSwitches() { - segs = append(segs, m.theme.srcShadowStyle.Render(sw.name)+" "+m.theme.switchStyle.Render("⟦"+sw.value+"⟧")) - } - switches := strings.Join(segs, searchSwitchSep) - help := m.theme.modalHint("^T mode · ^R dir · ^O origin · ↵ find · n/N next/prev · esc cancel") - - body := m.theme.modalTitle("Search") + "\n" + m.theme.modalHint(hint) + - "\n\n" + m.searchInput.View() + "\n\n" + switches + "\n\n" + help - return m.theme.modalStyle.Render(body) + return layout.Overlay(bg, modal, (m.width-mw)/2, (m.height-mh)/2) } // tabItems is the ordered tab strip, shared by renderTabs (drawing) and @@ -504,7 +125,22 @@ func (m *Model) tabLead() []string { } } +// renderTabs draws the tab strip. Like the footer, it is redrawn on every +// message but only *changes* with the active view, the width or the breadcrumb +// — and styling a tab means serialising its colours into ANSI, twelve times a +// frame. So the finished strip is memoised on exactly those inputs; +// clearColorCaches drops it on a theme change. func (m *Model) renderTabs() string { + crumb := m.breadcrumb() + if m.tabsCache != "" && m.tabsMode == m.mode && m.tabsWidth == m.width && m.tabsCrumb == crumb { + return m.tabsCache + } + out := m.buildTabs(crumb) + m.tabsCache, m.tabsMode, m.tabsWidth, m.tabsCrumb = out, m.mode, m.width, crumb + return out +} + +func (m *Model) buildTabs(crumb string) string { compact := m.tabsCompact() segs := m.tabLead() for _, t := range tabItems { @@ -514,17 +150,17 @@ func (m *Model) renderTabs() string { row := lipgloss.JoinHorizontal(lipgloss.Left, segs...) // When we've descended into another file (a dependency), show the breadcrumb // right-aligned in the tab strip so "where am I" is always visible. - if bc := m.breadcrumb(); bc != "" { + if crumb != "" { avail := m.width - lipgloss.Width(row) - 2 if avail >= 14 { - crumb := m.theme.footerStyle.Render(truncLeftWidth(bc, avail-9)) + m.theme.helpKeyStyle.Render(" ^O") + crumb := m.theme.footerStyle.Render(truncLeftWidth(crumb, avail-9)) + m.theme.helpKeyStyle.Render(" ^O") gap := max(1, m.width-lipgloss.Width(row)-lipgloss.Width(crumb)) return row + strings.Repeat(" ", gap) + crumb } } // Clamp to width: a too-wide tab strip would wrap and push the whole body // down a row (and the status line off-screen). - return padRight(row, m.width) + return layout.PadRight(row, m.width) } // truncLeftWidth trims s from the left to fit w columns, keeping the tail (the @@ -563,155 +199,126 @@ func (m *Model) tabHitTest(x int) (mode, bool) { // before it can render. Shared by the keyboard dispatch and tab clicks. It may // return a Cmd (the background disasm decode). func (m *Model) switchMode(md mode) tea.Cmd { - switch md { - case modeDisasm: + // Disasm is special: it needs the disassembler and sets the mode before its + // (possibly background) decode. Every other view prepares its lazy state, then + // the mode flips. + if md == modeDisasm { if m.dis == nil { m.setStatus("no disassembler for this architecture", true) return nil } m.setMode(modeDisasm) - if !m.disasmBuilt { - // Decode the initial window in the background; later jumps decode a - // fresh bounded span synchronously so targeted navigation lands - // immediately. - if !m.disasmDecoding { - m.disasmDecoding = true - m.disasmPendingAddr = m.disasmInitAddr - return m.decodeDisasmCmd(m.disasmInitAddr) - } - return nil - } - // Already decoded: land on the entry the first time in. - if !m.disasmPositioned && m.disasmInitAddr != 0 { - m.loadDisasmAt(m.disasmInitAddr) - } - return nil - case modeHex: - m.ensureHex() - case modeRaw: - m.ensureRaw() - case modeSymbols: - m.ensureSymbols() - case modeStrings: - m.ensureStrings() - case modeSources: - m.ensureSources() - case modeRelocs: - m.buildRelocFacets() - m.recomputeRelocs() + return disasmView{baseView{m}}.ensure() } + cmd := m.viewFor(md).ensure() m.setMode(md) - return nil + return cmd } // footerHint is one "key action" pair shown in the footer. type footerHint struct{ key, desc string } -// globalHints are the commands available everywhere; appended to every view's -// footer so they are never missing. The full reference lives behind '?'. -var globalHints = []footerHint{ - {"g", "goto"}, {",", "settings"}, {"?", "help"}, {"q", "quit"}, -} +// globalHints are appended to every view's footer, after a divider, so the way +// out is never missing. Deliberately just two: the footer is a reminder of the +// *view's* commands, and '?' is the complete reference — the other globals +// (g, ␣, f, l, ,) live there rather than eating a view's hint budget on a +// narrow terminal. +var globalHints = []footerHint{{"?", "help"}, {"q", "quit"}} // viewHints returns the current view's primary commands (view-specific only; // globals are appended by renderFooter). Kept curated — the complete list is in // the '?' overlay. func (m *Model) viewHints() []footerHint { - switch m.mode { - case modeInfo: - if m.isArchive() && m.infoMembers { - return []footerHint{{"↑/↓", "select"}, {"↵/t", "open member"}, {"esc", "back"}} - } - hints := []footerHint{{"↵", "disasm entry"}} - switch { - case m.isArchive(): - hints = append(hints, footerHint{"t", "members"}) - case len(m.file.FatArches) > 1: - hints = append(hints, footerHint{"t", "switch arch"}) - } - return hints - case modeSections: - return []footerHint{{"↵", "open"}, {"d/h/m", "go to"}, {"s/r", "sort/rev"}, {"t", "sec/seg"}, {"/", "filter"}, {ctrlKeys("t", "f"), "type/flags"}, {"⇧H", "header"}} - case modeSymbols: - if m.symbolTreeActive() { - return []footerHint{{"←/→", "fold/unfold"}, {"↵", "all below"}, {"+/−", "all"}, {"t", "flat"}} - } - return []footerHint{{"↵", "jump"}, {"d/h/m", "go to"}, {"s/r", "sort/rev"}, {"t", "tree"}, {"/", "filter"}, {ctrlKeys("t", "s", "b"), "type/scope/bind"}, {"⇧a/⇧s", "copy"}} - case modeDisasm: - dwarf := m.file.HasDWARF() - switch { - case m.searchRunning: - return []footerHint{{"esc", "cancel"}, {"[ ]", "sym"}, {"←/→", "history"}, {"/", "search"}} - case m.sourceFirst && m.srcFile != "": - // Source navigation leads: no disasm history, and [ ] steps mapped lines. - return []footerHint{{"↵", "to disasm"}, {"[ ]", "mapped"}, {"esc", "back"}, {"⇧tab", "swap"}, {"/", "search"}, {"⇧s", "copy"}} - case m.showSource && dwarf: - // Disasm-first with the source pane open. - return []footerHint{{"↵", "follow"}, {"[ ]", "sym"}, {"←/→", "history"}, {"x", "xrefs"}, {"y", "syscalls"}, {"h/m", "hex/raw"}, {"a", m.disasmAllHint()}, {"tab", "pane"}, {"⇧tab", "swap"}, {"/", "search"}, {"⇧a/⇧s/⇧c", "copy"}} - default: - // Disasm-first, no pane. Offer tab to open the pane only when there is - // debug info to show. - hints := []footerHint{{"↵", "follow"}, {"[ ]", "sym"}, {"←/→", "history"}, {"x", "xrefs"}, {"y", "syscalls"}, {"h/m", "hex/raw"}, {"a", m.disasmAllHint()}, {"/", "search"}, {"⇧a/⇧s/⇧c", "copy"}} - if dwarf { - hints = append(hints, footerHint{"tab", "pane"}) - } - return hints - } - case modeHex: - return []footerHint{{"↵", "follow ptr"}, {"d/m", "disasm/raw"}, {"[ ]", "section"}, {"t/⇧t", "ascii·interp"}, {"i", "inspect"}, {"/", "search"}, {"⇧a/⇧s/⇧p", "copy"}} - case modeRaw: - return []footerHint{{"↵", "follow ptr"}, {"d", "disasm"}, {"[ ]", "section"}, {"t/⇧t", "ascii·interp"}, {"i", "inspect"}, {"/", "search"}, {"⇧a/⇧s/⇧p", "copy"}} - case modeStrings: - return []footerHint{{"↵", "jump"}, {"d/h/m", "go to"}, {"s/r", "sort/rev"}, {"t", "table/flow"}, {"/", "filter"}, {ctrlKeys("s"), "section"}, {ctrlKeys("p"), "paths"}, {"⇧a/⇧s", "copy"}} - case modeSources: - if m.sourcesTree { - return []footerHint{{"←/→", "fold/unfold"}, {"↵", "open/all below"}, {ctrlKeys("p"), "present"}, {"t", "flat"}} + return m.current().hints() +} + +// renderFooter draws the hint bar. Bubble Tea re-renders the whole screen on +// every message, but the footer only *changes* when the view's hints, the +// terminal width or the status message do — and styling a hint means +// serialising its colour into ANSI, per hint, per frame. So the finished bar is +// memoised on exactly those inputs. +func (m *Model) renderFooter() string { + hints := m.viewHints() + if m.footerCache != "" && m.footerWidth == m.width && + m.footerStatus == m.status && m.footerStatusErr == m.statusError && + sameHints(m.footerHints, hints) { + return m.footerCache + } + out := m.buildFooter(hints) + m.footerCache = out + m.footerWidth = m.width + m.footerStatus = m.status + m.footerStatusErr = m.statusError + m.footerHints = append(m.footerHints[:0], hints...) + return out +} + +func sameHints(a, b []footerHint) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false } - return []footerHint{{"↵", "open"}, {"s/r", "sort/rev"}, {"t", "tree"}, {"/", "filter"}, {ctrlKeys("p"), "present"}, {"⇧s", "copy"}} - case modeLibs: - return []footerHint{{"↵", "imports"}, {"o", "open"}, {"r", "rev"}, {"t", "tree"}, {"/", "filter"}, {ctrlKeys("p"), "avail"}, {"⇧s", "copy"}} - case modeRelocs: - return []footerHint{{"↵", "go to patched addr"}, {"s/r", "sort/rev"}, {ctrlKeys("t", "s"), "type/section"}, {"/", "filter"}} } - return nil + return true } -func (m *Model) renderFooter() string { +func (m *Model) buildFooter(viewHints []footerHint) string { keyStyle := m.theme.helpKeyStyle // accent, bold descStyle := m.theme.footerStyle.Padding(0) // muted, no padding sep := descStyle.Render(" · ") - hints := m.viewHints() - if m.mode == modeInfo { - hints = append(hints, globalHints...) - } - parts := make([]string, 0, len(hints)) - for _, h := range hints { - parts = append(parts, keyStyle.Render(h.key)+" "+descStyle.Render(h.desc)) - } - - if m.status == "" { - // No message: hints fill the line, shrinking with an ellipsis if too wide. - return padRight(" "+fitJoin(parts, sep, m.width-1), m.width) - } + render := func(hints []footerHint) []string { + out := make([]string, 0, len(hints)) + for _, h := range hints { + out = append(out, keyStyle.Render(h.key)+" "+descStyle.Render(h.desc)) + } + return out + } + parts := render(viewHints) + // The globals are pinned to the right of the hint area rather than queued + // behind the view's hints: they are the way out, so they must not be the first + // thing an ellipsis eats on a narrow terminal. The view's own hints take the + // space that's left and shrink into it. + // + // They never change, so they are styled once rather than on every frame — + // styling a hint means serialising its colour into ANSI, and this is the + // hottest path in the app. clearColorCaches drops it on a theme change. + if m.footerGlobals == "" { + m.footerGlobals = descStyle.Render("│ ") + strings.Join(render(globalHints), sep) + } + globals := m.footerGlobals + globalsW := lipgloss.Width(globals) // A status message dominates: it keeps its full width on the right as a badge // (its semantic colour as the background — red for errors — with a contrasting - // foreground) and the hints shrink into whatever is left, so the two never + // foreground) and everything else shrinks into what is left, so the two never // overlap. - st := m.theme.infoStyle - if m.statusError { - st = m.theme.errorStyle + msg, msgW := "", 0 + if m.status != "" { + st := m.theme.infoStyle + if m.statusError { + st = m.theme.errorStyle + } + bg := st.GetForeground() + badge := lipgloss.NewStyle().Bold(true).Background(bg).Foreground(contrastOn(bg)) + msg = badge.Render(" " + m.status + " ") + if lipgloss.Width(msg) > m.width { + msg = layout.FitANSIWidth(msg, m.width) + } + msgW = lipgloss.Width(msg) } - bg := st.GetForeground() - badge := lipgloss.NewStyle().Bold(true).Background(bg).Foreground(contrastOn(bg)) - msg := badge.Render(" " + m.status + " ") - if lipgloss.Width(msg) > m.width { - msg = fitANSIWidth(msg, m.width) + + avail := m.width - msgW - 1 // the leading space + // Too cramped to show both: the view's hints win the space, since the globals + // are also in '?' and on the keyboard. + if avail < globalsW+8 { + return layout.PadRight(" "+fitJoin(parts, sep, avail), m.width-msgW) + msg } - msgW := lipgloss.Width(msg) - left := padRight(" "+fitJoin(parts, sep, m.width-msgW-1), m.width-msgW) - return left + msg + hints := fitJoin(parts, sep, avail-globalsW-2) + return layout.PadRight(" "+hints, m.width-msgW-globalsW) + globals + msg } // contrastOn returns near-black or near-white, whichever reads better on bg, by diff --git a/internal/ui/classcolor.go b/internal/ui/classcolor.go index d5aa545..a091d96 100644 --- a/internal/ui/classcolor.go +++ b/internal/ui/classcolor.go @@ -66,7 +66,7 @@ func (t Theme) styleForSymbol(k binfile.SymKind, b binfile.SymBind) lipgloss.Sty // styleForSection picks the row colour for a section based on its type/flags, // with the loader's neutral category as a fallback. This keeps section colours // useful across ELF, Mach-O, and PE even when format-specific type labels vary. -func (t Theme) styleForSection(s *binfile.Section) lipgloss.Style { +func (t *Theme) styleForSection(s *binfile.Section) lipgloss.Style { if s == nil { return t.tableRowStyle } @@ -93,6 +93,20 @@ func (t Theme) styleForSection(s *binfile.Section) lipgloss.Style { return t.tableRowStyle } +// styleForSegment picks the row colour for a segment from its permissions: +// executable segments reuse the .text row colour, writable ones the data +// colour, the rest read-only data — so segment colours read like the section +// table. +func (t *Theme) styleForSegment(exec, write bool) lipgloss.Style { + switch { + case exec: + return t.secTextStyle + case write: + return t.secDataStyle + } + return t.secRodataStyle +} + func sectionColorCategory(s *binfile.Section) binfile.SectionCategory { name := strings.ToLower(s.Name) typ := strings.ToLower(s.TypeName) @@ -126,33 +140,3 @@ func sectionColorCategory(s *binfile.Section) binfile.SectionCategory { } return s.Category } - -// kindString / bindString render neutral symbol kinds and bindings for the -// symbol table's Type and Bind columns. -func kindString(k binfile.SymKind) string { - switch k { - case binfile.SymFunc: - return "FUNC" - case binfile.SymObject: - return "OBJECT" - case binfile.SymSection: - return "SECTION" - case binfile.SymFile: - return "FILE" - case binfile.SymTLS: - return "TLS" - case binfile.SymCommon: - return "COMMON" - } - return "NOTYPE" -} - -func bindString(b binfile.SymBind) string { - switch b { - case binfile.BindGlobal: - return "GLOBAL" - case binfile.BindWeak: - return "WEAK" - } - return "LOCAL" -} diff --git a/internal/ui/copyline.go b/internal/ui/copyline.go index bfe77c9..d8e879a 100644 --- a/internal/ui/copyline.go +++ b/internal/ui/copyline.go @@ -5,7 +5,6 @@ package ui // own row renderer and strips styling so the clipboard gets clean text. import ( - "fmt" "strings" "github.com/charmbracelet/x/ansi" @@ -29,67 +28,13 @@ func (m *Model) copyCurrentLine() bool { // currentLineText returns the plain text of the current row in the active view. func (m *Model) currentLineText() (string, bool) { - addrW := m.file.AddrHexWidth() - clean := func(s string) string { - return strings.TrimSpace(collapseSpaces(ansi.Strip(s))) - } - switch m.mode { - case modeSections: - if m.sectionsCur < 0 || m.sectionsCur >= len(m.sectionsFiltered) { - return "", true - } - return clean(m.sectionRow(m.sectionsCur, addrW)), true - case modeSymbols: - if m.symbolsCur < 0 || m.symbolsCur >= len(m.symbolsRows) { - return "", true - } - return clean(strings.Join(m.symbolRows(m.symbolsCur, addrW), " ")), true - case modeStrings: - m.ensureStrings() - if m.stringsCur < 0 || m.stringsCur >= len(m.stringsFiltered) { - return "", true - } - return clean(m.stringRow(m.stringsCur, addrW)), true - case modeLibs: - if m.libsCur < 0 || m.libsCur >= len(m.libsRows) { - return "", true - } - return clean(m.libRow(m.libsCur, false)), true - case modeSources: - if m.srcFile != "" { - return m.srcFile, true // open file: copy its path - } - if f, ok := m.sourceFileAt(m.sourcesCur); ok { - return f, true - } - if m.sourcesCur >= 0 && m.sourcesCur < len(m.sourcesRows) { - return m.sourcesRows[m.sourcesCur].node.label, true - } - return "", true - case modeDisasm: - if len(m.disasmInst) == 0 || m.disasmCur < 0 || m.disasmCur >= len(m.disasmInst) { - return "", true - } - in := m.disasmInst[m.disasmCur] - return clean(fmt.Sprintf("0x%0*x %s %s", addrW, in.Addr, ansi.Strip(bytesHex(in.Bytes, len(in.Bytes))), in.Text)), true - case modeHex: - m.ensureHex() - return clean(m.byteRowText(modeHex, m.hexImg, m.hexCur, m.hexImg.AddrAt)), true - case modeRaw: - m.ensureRaw() - return clean(m.byteRowText(modeRaw, rawBytes(m.rawData), m.rawCur, identityAddr)), true - } - return "", false + return m.current().lineText() } -// byteRowText renders the hex/raw row containing the cursor and strips styling. -func (m *Model) byteRowText(md mode, data byteSource, cur int, addrAt func(int) uint64) string { - if data.Len() == 0 { - return "" - } - start := m.hexRowTop(md, cur, addrAt) - span := m.hexRowSpan(md, data, start, addrAt) - return ansi.Strip(m.renderHexRow(md, data, cur, span, m.file.AddrHexWidth(), addrAt)) +// cleanCopyLine strips styling and squeezes column padding so a table row copies +// as a single readable, space-separated line. +func cleanCopyLine(s string) string { + return strings.TrimSpace(collapseSpaces(ansi.Strip(s))) } // collapseSpaces squeezes runs of spaces to one, so a wide table row copies as a diff --git a/internal/ui/cpufeatures.go b/internal/ui/cpufeatures.go index abc3c2f..6401d7d 100644 --- a/internal/ui/cpufeatures.go +++ b/internal/ui/cpufeatures.go @@ -2,31 +2,27 @@ package ui // CPU-feature detection: scan the executable image, classify each instruction // into the CPU-feature families it requires (SSE/AVX/NEON/…), and show the set -// plus the implied baseline in a modal. Enter jumps to the first use of the -// selected feature. The scan runs off the UI goroutine and is cancellable, like -// the cross-reference / syscall scans. +// plus the implied baseline in a modal. +// +// The overlay itself lives in internal/ui/modals/cpufeat. What stays here is the +// async orchestration — launching the scan as a tea.Cmd, guarding its result +// against a stale file or a superseded sequence number, and cancelling it — all +// of which is the shell's job because only the shell owns the event loop. import ( "fmt" - "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/dump" ) -// cpufeatState holds the CPU-feature scan + modal state. +// cpufeatState holds the CPU-feature scan's async bookkeeping. The overlay's own +// state (the result, the selection, the scroll) lives on m.cpufeat. type cpufeatState struct { - cpufeatActive bool cpufeatRunning bool cpufeatSeq int - cpufeatSet dump.CPUFeatureSet - cpufeatFeats []string // feature names in display order - cpufeatSel int - cpufeatTop int - cpufeatDone bool cpufeatCancel chan struct{} } @@ -43,9 +39,9 @@ func (m *Model) startCPUFeatScan() tea.Cmd { m.setStatus("no disassembler for this architecture", true) return nil } - if m.cpufeatDone { - m.openCPUFeatModal(m.cpufeatSet) - m.setStatus(fmt.Sprintf("%d CPU features (cached)", len(m.cpufeatFeats)), false) + if m.cpufeat.Scanned() { + m.cpufeat.Open(m.cpufeat.Set()) + m.setStatus(fmt.Sprintf("%d CPU features (cached)", len(m.cpufeat.Features())), false) return nil } m.stopCPUFeatScan() @@ -56,10 +52,10 @@ func (m *Model) startCPUFeatScan() tea.Cmd { done := make(chan struct{}) m.cpufeatCancel = done m.setStatus("scanning for CPU features … (Esc cancels)", false) - return func() tea.Msg { + return m.backgroundCmd(func() tea.Msg { set, err := dump.ScanCPUFeaturesCancel(file, done) return cpufeatDoneMsg{file: file, seq: seq, set: set, err: err} - } + }) } func (m *Model) handleCPUFeatDone(msg cpufeatDoneMsg) (tea.Model, tea.Cmd) { @@ -72,23 +68,15 @@ func (m *Model) handleCPUFeatDone(msg cpufeatDoneMsg) (tea.Model, tea.Cmd) { m.setStatus(msg.err.Error(), true) return m, nil } - m.cpufeatDone = true - m.openCPUFeatModal(msg.set) + m.cpufeat.Open(msg.set) base := "" if msg.set.Baseline != "" { base = " · " + msg.set.Baseline } - m.setStatus(fmt.Sprintf("%d CPU features%s", len(m.cpufeatFeats), base), false) + m.setStatus(fmt.Sprintf("%d CPU features%s", len(m.cpufeat.Features()), base), false) return m, nil } -func (m *Model) openCPUFeatModal(set dump.CPUFeatureSet) { - m.cpufeatSet = set - m.cpufeatFeats = set.SortedFeatures() - m.cpufeatSel, m.cpufeatTop = 0, 0 - m.cpufeatActive = true -} - func (m *Model) cancelCPUFeat() { m.cpufeatSeq++ m.cpufeatRunning = false @@ -102,84 +90,3 @@ func (m *Model) stopCPUFeatScan() { m.cpufeatCancel = nil } } - -// updateCPUFeatModal: navigate the feature list, Enter jumps to the first use of -// the selected feature, Esc closes. -func (m *Model) updateCPUFeatModal(key string) (tea.Model, tea.Cmd) { - switch key { - case "esc": - m.cpufeatActive = false - case "up", "k": - if m.cpufeatSel > 0 { - m.cpufeatSel-- - } - case "down", "j": - if m.cpufeatSel < len(m.cpufeatFeats)-1 { - m.cpufeatSel++ - } - case "enter": - return m.cpufeatJump() - } - return m, nil -} - -func (m *Model) cpufeatJump() (tea.Model, tea.Cmd) { - if m.cpufeatSel < 0 || m.cpufeatSel >= len(m.cpufeatFeats) { - return m, nil - } - addr, ok := m.cpufeatSet.FirstUse[m.cpufeatFeats[m.cpufeatSel]] - if !ok { - return m, nil - } - m.cpufeatActive = false - m.loadDisasmAt(addr) - return m, nil -} - -func (m *Model) renderCPUFeatModal() string { - var sb strings.Builder - rowW := modalListWidth(m.width) - addrW := m.file.AddrHexWidth() - visible := clamp(m.height-9, 3, 40) - - sb.WriteString(m.theme.modalTitle("CPU features")) - sb.WriteString("\n") - sub := fmt.Sprintf("%d instructions scanned", m.cpufeatSet.Total) - if m.cpufeatSet.Baseline != "" { - sub = m.theme.warnStyle.Render(m.cpufeatSet.Baseline) + m.theme.modalHint(" · "+sub) - } else { - sub = m.theme.modalHint(sub) - } - sb.WriteString(fitANSIWidth(sub, rowW)) - sb.WriteString("\n\n") - m.modalListRow = 3 - - if len(m.cpufeatFeats) == 0 { - sb.WriteString(" " + m.theme.srcShadowStyle.Render("only base instructions — no optional CPU features detected") + "\n") - } - nameW := 0 - for _, f := range m.cpufeatFeats { - nameW = max(nameW, len(f)) - } - nameW = clamp(nameW, 8, 28) - top := visualTop(m.cpufeatSel, m.cpufeatTop, len(m.cpufeatFeats), visible, func(int) int { return 1 }) - m.cpufeatTop = top - end := min(top+visible, len(m.cpufeatFeats)) - for i := top; i < end; i++ { - f := m.cpufeatFeats[i] - line := fmt.Sprintf(" %s %s × %s", - m.theme.infoStyle.Render(padVisual(f, nameW)), - padVisual(fmt.Sprintf("%d", m.cpufeatSet.Counts[f]), 8), - m.theme.srcShadowStyle.Render(fmt.Sprintf("first at 0x%0*x", addrW, m.cpufeatSet.FirstUse[f]))) - line = padRight(line, rowW) - if i == m.cpufeatSel { - line = m.theme.tableSelStyle.Render(ansi.Strip(line)) - } - sb.WriteString(line) - sb.WriteString("\n") - } - sb.WriteString("\n") - sb.WriteString(m.theme.modalHint(fmt.Sprintf("↑/↓ select · ↵ jump to first use · Esc close (%d/%d)", - min(m.cpufeatSel+1, len(m.cpufeatFeats)), len(m.cpufeatFeats)))) - return m.theme.modalStyle.Render(sb.String()) -} diff --git a/internal/ui/crossfile.go b/internal/ui/crossfile.go index 2f95a52..4985979 100644 --- a/internal/ui/crossfile.go +++ b/internal/ui/crossfile.go @@ -17,6 +17,7 @@ import ( // pushed on top, takes m's size, and records its breadcrumb label. Every file // switch (dependency / member / arch) routes through this so Back works uniformly. func (m *Model) enterFile(nm *Model, label string) { + m.suspendBackground() nm.fileStack = append(append([]*Model(nil), m.fileStack...), m) nm.fileLabel = label nm.width, nm.height = m.width, m.height @@ -31,11 +32,12 @@ func (m *Model) goBackFile() (tea.Model, tea.Cmd, bool) { return m, nil, false } prev := m.fileStack[n-1] - prev.fileStack = m.fileStack[:n-1 : n-1] + m.retireFile() + prev.fileStack = m.fileStack[: n-1 : n-1] prev.width, prev.height = m.width, m.height prev.viewDirty = true prev.setStatus("back to "+prev.breadcrumbLeaf(), false) - return prev, nil, true + return prev, prev.Init(), true } // breadcrumbLeaf is this model's own file name (the last breadcrumb segment). diff --git a/internal/ui/crossfile_lifetime_test.go b/internal/ui/crossfile_lifetime_test.go new file mode 100644 index 0000000..ce05e11 --- /dev/null +++ b/internal/ui/crossfile_lifetime_test.go @@ -0,0 +1,116 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" +) + +func openLifetimeTestModel(t *testing.T) *Model { + t.Helper() + path := firstExisting("/bin/ls", "/usr/bin/true", "/bin/cat") + if path == "" { + t.Skip("no system binary available") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatal(err) + } + m, err := New(f) + if err != nil { + f.Close() + t.Fatal(err) + } + t.Cleanup(func() { f.Close() }) + return m +} + +func TestGoBackClosesIdleChildFile(t *testing.T) { + parent := openLifetimeTestModel(t) + child := openLifetimeTestModel(t) + child.fileStack = []*Model{parent} + + got, _, ok := child.goBackFile() + if !ok || got != parent { + t.Fatal("goBackFile did not restore parent") + } + if child.file.Raw() != nil { + t.Fatal("idle child mapping remained open") + } + if parent.file.Raw() == nil { + t.Fatal("restored parent mapping was closed") + } +} + +func TestGoBackDefersCloseUntilBackgroundCommandReturns(t *testing.T) { + parent := openLifetimeTestModel(t) + child := openLifetimeTestModel(t) + child.fileStack = []*Model{parent} + + started := make(chan struct{}) + release := make(chan struct{}) + cmd := child.backgroundCmd(func() tea.Msg { + close(started) + <-release + return prewarmMsg{} + }) + result := make(chan tea.Msg, 1) + go func() { result <- cmd() }() + <-started + + got, _, ok := child.goBackFile() + if !ok || got != parent { + t.Fatal("goBackFile did not restore parent") + } + if child.file.Raw() == nil { + t.Fatal("child mapping closed while its command was still running") + } + + close(release) + model, next := parent.Update(<-result) + if model != parent || next != nil { + t.Fatal("stale child completion affected restored parent") + } + if child.file.Raw() != nil { + t.Fatal("child mapping remained open after its final command returned") + } + if parent.dasm.Decoding { + t.Fatal("stale child prewarm started a decode on the parent") + } +} + +func TestEnterFileCancelsParentWork(t *testing.T) { + parent := openLifetimeTestModel(t) + child := openLifetimeTestModel(t) + + channels := []chan struct{}{make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{})} + parent.findCancel = channels[0] + parent.searchCancel = channels[1] + parent.xrefCancel = channels[2] + parent.syscallCancel = channels[3] + parent.syscallFullCancel = channels[4] + parent.cpufeatCancel = channels[5] + parent.searchRunning = true + parent.xrefRunning = true + parent.syscallRunning = true + parent.syscallFullRunning = true + parent.cpufeatRunning = true + parent.dasm.Decoding = true + + parent.enterFile(child, "child") + for i, ch := range channels { + select { + case <-ch: + default: + t.Fatalf("cancellation channel %d was not closed", i) + } + } + if parent.searchRunning || parent.xrefRunning || parent.syscallRunning || parent.syscallFullRunning || parent.cpufeatRunning || parent.dasm.Decoding { + t.Fatal("parent still reports background work after suspension") + } + if parent.file.Raw() == nil { + t.Fatal("suspended parent mapping was closed") + } +} diff --git a/internal/ui/disasm_columns_test.go b/internal/ui/disasm_columns_test.go new file mode 100644 index 0000000..9b21553 --- /dev/null +++ b/internal/ui/disasm_columns_test.go @@ -0,0 +1,91 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/config" + "github.com/rabarbra/exex/internal/dump" + disasmview "github.com/rabarbra/exex/internal/ui/views/disasm" +) + +// TestAsmColumnMatchesTheRenderedPrefix ties Columns.Asm to the row format. +// +// disasmInstRows builds a row's prefix with a format string (" %s %s "), not +// from Columns.Asm; Asm is used only to size the assembly truncation and to place +// the annotation. Because the annotation is positioned by a *difference* +// (inlineStart - asmEnd), an off-by-one in Asm cancels out there, and short +// instructions on a wide view never truncate — so a wrong Asm can leave every +// golden frame byte-identical and only misbehave on a narrow pane. This is the +// assertion that would catch it. +func TestAsmColumnMatchesTheRenderedPrefix(t *testing.T) { + for _, tc := range []struct { + name string + hide, space bool + }{ + {"bytes shown, compact", false, false}, + {"bytes shown, spaced", false, true}, + {"bytes hidden", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := goldenModel(t) + m.cfg.Behavior.HideDisasmBytes = tc.hide + m.cfg.Behavior.SpacedDisasmBytes = tc.space + enterMode(t, m, modeDisasm) + if len(m.dasm.Inst) == 0 { + t.Fatal("fixture decoded no instructions") + } + + cols := m.disasmColumns() + inst := m.dasm.Inst[0] + rows := m.dasm.InstRows(m.viewContextPtr(), inst, m.width, false, nil) + if len(rows) == 0 { + t.Fatal("disasmInstRows returned nothing") + } + plain := ansi.Strip(rows[0]) + + // The assembly *field* begins at Asm; everything before it is the lead + // space, the address and (maybe) the byte column. The mnemonic itself sits + // further right, because AlignAsm right-aligns it within the field — so + // look for the aligned text, not the bare mnemonic. + aligned := dump.AlignAsm(inst.Text) + at := strings.Index(plain, aligned) + if at < 0 { + t.Fatalf("aligned assembly %q not found in row %q", aligned, plain) + } + if at != cols.Asm { + t.Errorf("assembly field renders at column %d but Columns.Asm says %d\n row: %q", + at, cols.Asm, plain) + } + // And nothing but padding precedes it beyond the address/byte columns. + if got := len(plain[:at]); got != cols.Asm { + t.Errorf("prefix width %d != Asm %d", got, cols.Asm) + } + }) + } +} + +// TestByteColumnWidthMatchesTheRenderedBytes pins ByteColW to what disasmBytes +// actually prints, in both the compact and spaced spellings. +func TestByteColumnWidthMatchesTheRenderedBytes(t *testing.T) { + for _, spaced := range []bool{false, true} { + m := goldenModel(t) + m.cfg = config.Config{Theme: defaultThemeName} + m.cfg.Behavior.SpacedDisasmBytes = spaced + enterMode(t, m, modeDisasm) + if len(m.dasm.Inst) == 0 { + t.Fatal("fixture decoded no instructions") + } + cols := m.disasmColumns() + // The longest encoding the column is sized for; a shorter instruction pads + // to the same width, which is what keeps the assembly column straight. + for _, inst := range m.dasm.Inst { + if got := ansi.StringWidth(disasmview.InstBytes(m.viewContextPtr(), inst.Bytes)); got != cols.ByteColW { + t.Fatalf("spaced=%v: disasmBytes(%d bytes) printed width %d, ByteColW = %d", + spaced, len(inst.Bytes), got, cols.ByteColW) + } + } + } +} diff --git a/internal/ui/disasm_decode.go b/internal/ui/disasm_decode.go index 8f2057e..8020643 100644 --- a/internal/ui/disasm_decode.go +++ b/internal/ui/disasm_decode.go @@ -6,14 +6,13 @@ package ui // index helpers that locate an instruction by address. import ( - "sort" - tea "charm.land/bubbletea/v2" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/disasm" "github.com/rabarbra/exex/internal/dump" "github.com/rabarbra/exex/internal/explorer" + disasmview "github.com/rabarbra/exex/internal/ui/views/disasm" ) // functionInsts decodes the instructions making up sym's extent, fresh from the @@ -28,11 +27,9 @@ func (m *Model) functionInsts(sym binfile.Symbol) []disasm.Inst { // disasmReadyMsg carries the finished decode from the background worker. type disasmReadyMsg struct { - file *binfile.File - addr uint64 - posLo int - posHi int - insts []disasm.Inst + file *binfile.File + addr uint64 + span explorer.Span } type disasmPrefetchMsg struct{} @@ -62,14 +59,14 @@ func (m *Model) prefetchDisasmAroundCmd(addr uint64) tea.Cmd { return nil } svc := m.disasmService() - return func() tea.Msg { + return m.backgroundCmd(func() tea.Msg { svc.PrefetchAround(addr) return disasmPrefetchMsg{} - } + }) } -func (m *Model) decodeDisasmAt(addr uint64, before int) (binfile.Window, []disasm.Inst) { - return m.disasmService().DecodeAt(addr, before) +func (m *Model) decodeDisasmAt(addr uint64, before int) explorer.Span { + return m.disasmService().DecodeSpanAt(addr, before) } // ensureDisasm decodes synchronously on first use. It's the path jumps take @@ -77,12 +74,12 @@ func (m *Model) decodeDisasmAt(addr uint64, before int) (binfile.Window, []disas // can't defer. Returns false when there's no disassembler or no code. The // view-switch path uses the asynchronous decodeDisasmCmd instead. func (m *Model) ensureDisasm() bool { - if m.disasmBuilt { - return m.dis != nil && len(m.disasmInst) > 0 + if m.dasm.Built { + return m.dis != nil && len(m.dasm.Inst) > 0 } - m.disasmBuilt = true - m.disasmDecoding = false - m.disasmPendingAddr = 0 + m.dasm.Built = true + m.dasm.Decoding = false + m.dasm.PendingAddr = 0 if m.dis == nil { return false } @@ -90,11 +87,11 @@ func (m *Model) ensureDisasm() bool { if target == 0 { target = explorer.DefaultExecAddr(m.file, m.disasmTarget) } - win, insts := m.decodeDisasmAt(target, m.disasmLeadBytes()) - m.disasmInst = insts - m.disasmPosLo, m.disasmPosHi = m.posLoFor(win.Start, insts), win.End - m.disasmHeightCache = nil - return len(m.disasmInst) > 0 + span := m.decodeDisasmAt(target, m.disasmLeadBytes()) + m.dasm.Inst = span.Insts + m.dasm.PosLo, m.dasm.PosHi = span.PosLo, span.PosHi + m.dasm.HeightCache = nil + return len(m.dasm.Inst) > 0 } // decodeDisasmCmd decodes a bounded executable span off the main goroutine and @@ -103,129 +100,28 @@ func (m *Model) decodeDisasmCmd(addr uint64) tea.Cmd { svc := m.disasmService() file := m.file before := svc.LeadBytes() - return func() tea.Msg { - win, insts := svc.DecodeAt(addr, before) - return disasmReadyMsg{file: file, addr: addr, posLo: win.Start, posHi: win.End, insts: insts} - } + return m.backgroundCmd(func() tea.Msg { + span := svc.DecodeSpanAt(addr, before) + return disasmReadyMsg{file: file, addr: addr, span: span} + }) } +// disasmLoadedAddr reports whether addr is inside the decoded window *and* lands +// on an instruction there. func (m *Model) disasmLoadedAddr(addr uint64) bool { - if len(m.disasmInst) == 0 { - return false - } - pos, ok := m.file.ExecImage().PosForAddr(addr) - if !ok || pos < m.disasmPosLo || pos >= m.disasmPosHi { - return false - } - _, ok = m.instIndexForAddr(addr) - return ok + return m.dasm.LoadedAddr(m.file, addr) } -func (m *Model) disasmHasExactInst(addr uint64) bool { - if len(m.disasmInst) == 0 { - return false - } - i := sort.Search(len(m.disasmInst), func(i int) bool { return m.disasmInst[i].Addr >= addr }) - return i < len(m.disasmInst) && m.disasmInst[i].Addr == addr -} - -// instIndexForAddr finds the instruction covering addr (or the nearest one at -// a lower address). ok reports whether addr actually falls within the returned -// instruction's bytes. -func (m *Model) instIndexForAddr(addr uint64) (idx int, ok bool) { - insts := m.disasmInst - if len(insts) == 0 { - return 0, false - } - i := sort.Search(len(insts), func(i int) bool { return insts[i].Addr > addr }) - if i == 0 { - return 0, false - } - j := i - 1 - in := insts[j] - if addr >= in.Addr && addr < in.Addr+uint64(len(in.Bytes)) { - return j, true - } - return j, in.Addr == addr -} - -// instIndexAtOrAfterAddr returns the first instruction at or after addr, or the -// last preceding instruction when there is no later one in the loaded window. -func (m *Model) instIndexAtOrAfterAddr(addr uint64) int { - insts := m.disasmInst - if len(insts) == 0 { - return 0 - } - idx, ok := m.instIndexForAddr(addr) - if ok { - return idx - } - i := sort.Search(len(insts), func(i int) bool { return insts[i].Addr >= addr }) - if i < len(insts) { - return i - } - if idx >= 0 && idx < len(insts) { - return idx - } - return len(insts) - 1 -} - -// posLoFor returns the image position of the first decoded instruction, which is -// what bounds "is there code above / where does it start". It differs from the -// decode window's Start when DecodeAt began at a symbol (a section/function jump): -// the window reserves lead bytes that hold no decoded instructions, so anchoring -// scroll-up on win.Start would jump far before the actual preceding code. -func (m *Model) posLoFor(winStart int, insts []disasm.Inst) int { - if len(insts) > 0 { - if p, ok := m.file.ExecImage().PosForAddr(insts[0].Addr); ok { - return p - } - } - return winStart -} - -func (m *Model) setDisasmWindow(win binfile.Window, insts []disasm.Inst) bool { - // Never clobber a good window with an empty decode (e.g. a step that ran off - // the end): keep what we have so the cursor stays valid. - if len(insts) == 0 && len(m.disasmInst) > 0 { - return false - } - m.disasmInst = insts - m.disasmPosLo = m.posLoFor(win.Start, insts) - m.disasmPosHi = win.End - m.disasmBuilt = true - m.disasmDecoding = false - m.disasmPendingAddr = 0 - m.sourceAsmRowCache = nil - m.disasmHeightCache = nil - return len(insts) > 0 +func (m *Model) loadDisasmWindow(addr uint64, before int) bool { + return m.dasm.LoadWindow(m.dasmEnv(), addr, before) } -func (m *Model) loadDisasmWindow(addr uint64, before int) bool { - win, insts := m.decodeDisasmAt(addr, before) - if !m.setDisasmWindow(win, insts) { - m.setStatus("no executable code to disassemble", true) - return false - } - m.setMode(modeDisasm) - return true +// dasmEnv bundles what the disasm view's navigation needs from the shell. +func (m *Model) dasmEnv() disasmview.Env { + return disasmview.Env{File: m.file, Svc: m.disasmService(), Host: m} } -func (m *Model) loadDisasmWindowEnding(end int) bool { - img := m.file.ExecImage() - if end <= 0 || img.Len() == 0 { - return false - } - if end > img.Len() { - end = img.Len() - } - start := max(0, end-m.disasmMaxBytes) - win := img.Window(start, end-start) - insts := m.disasmService().DecodeWindow(win) - if !m.setDisasmWindow(win, insts) { - m.setStatus("no executable code to disassemble", true) - return false - } +// ShowDisasmView implements disasmview.Host. +func (m *Model) ShowDisasmView() { m.setMode(modeDisasm) - return true } diff --git a/internal/ui/disasm_mouse_test.go b/internal/ui/disasm_mouse_test.go new file mode 100644 index 0000000..70a6d0e --- /dev/null +++ b/internal/ui/disasm_mouse_test.go @@ -0,0 +1,124 @@ +package ui + +import ( + "testing" + + "github.com/rabarbra/exex/internal/binfile" +) + +// TestDisasmRenderWidthMatchesTheRenderedSplit pins RenderWidth — which the +// scroll, click and row-height math all resolve rows at — to the width +// renderDisasm actually hands the scroller. If it drifts, rows are measured at +// one width and drawn at another, so a click lands on the wrong instruction. +// A golden frame cannot catch it: the frame still renders correctly. +func TestDisasmRenderWidthMatchesTheRenderedSplit(t *testing.T) { + f := openDebugSample(t) + m, err := New(f) + if err != nil { + t.Fatalf("New: %v", err) + } + m.width, m.height = 120, 40 + m.setMode(modeDisasm) + ctx := m.viewContextPtr() + + // Disasm-first with the source pane open: renderDisasm splits the screen in + // half and renders the scroller into the left pane. + m.dasm.ShowSource, m.dasm.SourceFirst = true, false + if got, want := m.dasm.RenderWidth(ctx), m.width/2; got != want { + t.Errorf("with the source pane open RenderWidth = %d, but the scroller is drawn at %d", got, want) + } + // No pane: the scroller has the full width. + m.dasm.ShowSource = false + if got, want := m.dasm.RenderWidth(ctx), m.width; got != want { + t.Errorf("with no source pane RenderWidth = %d, want the full width %d", got, want) + } +} + +// openDebugSample opens a freshly built DWARF sample whose source resolves. +func openDebugSample(t *testing.T) *binfile.File { + t.Helper() + f, err := binfile.Open(buildResolvableDebugSample(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { f.Close() }) + if !f.HasDWARF() { + t.Skip("debug sample has no DWARF") + } + return f +} + +// TestClickRoutesBetweenTheSourceAndDisasmPanes pins which pane a click lands +// in when the source-first split is open: the left half moves the source cursor +// (and drags the disasm cursor with it), the right half moves the disasm cursor +// and leaves the source alone. +func TestClickRoutesBetweenTheSourceAndDisasmPanes(t *testing.T) { + path := buildResolvableDebugSample(t) + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + if !f.HasDWARF() { + t.Skip("debug sample has no DWARF") + } + m, err := New(f) + if err != nil { + t.Fatalf("New: %v", err) + } + m.width, m.height = 120, 40 + + var main binfile.Symbol + for _, s := range f.Symbols { + if s.Name == "main" || s.Name == "_main" { + main = s + break + } + } + if main.Name == "" { + t.Skip("debug sample has no main symbol") + } + file, line := f.LookupAddr(main.Addr) + if file == "" || line == 0 || f.SourceLines(file) == nil { + t.Skip("main has no resolvable source mapping") + } + m.openSourceFileInDisasm(file, line) + _ = m.View() // the click math reads the tops the render recorded + + // Left half, a couple of rows down: the source cursor takes the click. + const bodyRow = 3 + wantLine, ok := m.dasm.SourceLineAtRow(m.viewContextPtr(), bodyRow) + if !ok { + t.Skip("no source line under the test row") + } + srcBefore := m.dasm.SrcCur + m.handleClick(4, bodyRow+1) // handleClick takes screen y (body starts at y=1) + if m.dasm.SrcCur != wantLine { + t.Errorf("click in the source pane set SrcCur = %d, want the line under it (%d)", m.dasm.SrcCur, wantLine) + } + if wantLine != srcBefore { + // The disasm cursor follows the source cursor (SyncSourceAsm). + if addr, ok := f.LineToAddr(file, m.dasm.SrcCur); ok { + if cur, ok := m.dasm.CurAddr(); !ok || cur != addr { + t.Errorf("the disasm cursor did not follow the clicked line: at %#x, want %#x", cur, addr) + } + } + } + + // Right half: the disasm cursor takes the click, the source cursor stays. + if len(m.dasm.Inst) < 2 { + t.Skip("not enough instructions for the right-pane click") + } + srcNow := m.dasm.SrcCur + wantInst, ok := m.dasm.InstAtRow(m.viewContextPtr(), bodyRow) + if !ok { + t.Skip("no instruction under the test row") + } + m.handleClick(m.width-4, bodyRow+1) + if m.dasm.Cur != wantInst { + t.Errorf("click in the asm pane set Cur = %d, want the instruction under it (%d)", m.dasm.Cur, wantInst) + } + if m.dasm.SrcCur != srcNow { + t.Errorf("a click in the asm pane moved the source cursor: %d → %d", srcNow, m.dasm.SrcCur) + } +} diff --git a/internal/ui/disasm_nav.go b/internal/ui/disasm_nav.go index 2929a53..a6a8aee 100644 --- a/internal/ui/disasm_nav.go +++ b/internal/ui/disasm_nav.go @@ -10,17 +10,14 @@ import ( "github.com/rabarbra/exex/internal/explorer" ) -// historyCap caps the depth of the back/forward stack in the disasm view. -const historyCap = 30 - // loadDisasmAt moves the disasm cursor to addr and records the jump in the // back/forward history. The cursor position the user is leaving behind is // snapshotted into the *previous* history entry first, so the back arrow lands // them on the exact instruction they jumped from. func (m *Model) loadDisasmAt(addr uint64) { - m.snapshotCursorToHistory() + m.dasm.SnapshotCursorToHistory() if m.loadDisasmAtNoHistory(addr) { - m.pushHistory(m.disasmInst[m.disasmCur].Addr) + m.dasm.PushHistory(m.dasm.Inst[m.dasm.Cur].Addr) } } @@ -49,7 +46,7 @@ func (m *Model) loadDisasmAtNoHistory(addr uint64) bool { } reload := !m.disasmLoadedAddr(target) if !reload { - if sym, ok := m.file.SymbolAt(target); ok && sym.Addr == target && !m.disasmHasExactInst(target) { + if sym, ok := m.file.SymbolAt(target); ok && sym.Addr == target && !m.dasm.HasExact(target) { reload = true } } @@ -58,11 +55,11 @@ func (m *Model) loadDisasmAtNoHistory(addr uint64) bool { return false } } - idx := m.instIndexAtOrAfterAddr(target) - m.disasmCur = idx - m.disasmTop = idx - m.renderedDisasmTop = m.disasmTop - m.disasmPositioned = true + idx := m.dasm.IndexAtOrAfter(target) + m.dasm.Cur = idx + m.dasm.Top = idx + m.dasm.RenderedTop = m.dasm.Top + m.dasm.Positioned = true m.viewportDetached = false m.setMode(modeDisasm) return true @@ -73,252 +70,64 @@ func (m *Model) disasmViewportHeight() int { } func (m *Model) ensureDisasmViewport(h int) { - if len(m.disasmInst) == 0 || h < 1 { - return - } - img := m.file.ExecImage() - curAddr := m.disasmInst[m.disasmCur].Addr - for tries := 0; tries < 2; tries++ { - if m.disasmCur < m.disasmTop { - m.disasmTop = m.disasmCur - } else if m.disasmCur >= m.disasmTop+h { - m.disasmTop = m.disasmCur - h + 1 - } - end := min(len(m.disasmInst), m.disasmTop+h) - needAbove := m.disasmTop == 0 && m.disasmCur == 0 && m.disasmPosLo > 0 - needBelow := end == len(m.disasmInst) && m.disasmCur == len(m.disasmInst)-1 && m.disasmPosHi < img.Len() - if !needAbove && !needBelow { - return - } - if needAbove { - before := m.disasmMaxBytes - m.disasmOverlapBytes() - if !m.loadDisasmWindow(img.AddrAt(m.disasmPosLo-1), before) { - return - } - } else { - last := m.disasmInst[len(m.disasmInst)-1] - nextAddr := last.Addr + uint64(len(last.Bytes)) - if _, ok := img.PosForAddr(nextAddr); !ok || !m.loadDisasmWindow(nextAddr, m.disasmOverlapBytes()) { - return - } - } - m.disasmCur = m.instIndexAtOrAfterAddr(curAddr) - if m.disasmCur >= h { - m.disasmTop = m.disasmCur - min(m.disasmCur, h/2) - } else { - m.disasmTop = 0 - } - } -} - -func (m *Model) loadDisasmWindowForStep(forward bool) bool { - if len(m.disasmInst) == 0 { - return false - } - img := m.file.ExecImage() - if forward { - last := m.disasmInst[len(m.disasmInst)-1] - nextAddr := last.Addr + uint64(len(last.Bytes)) - if _, ok := img.PosForAddr(nextAddr); !ok { - m.setStatus("at end of executable code", false) - return false - } - if !m.loadDisasmWindow(nextAddr, m.disasmOverlapBytes()) { - return false - } - idx, _ := m.instIndexForAddr(nextAddr) - m.disasmCur = idx - m.scrollDisasmContext(3) - return true - } - firstAddr := m.disasmInst[0].Addr - pos, ok := img.PosForAddr(firstAddr) - if !ok || pos == 0 { - m.setStatus("at start of executable code", false) - return false - } - if !m.loadDisasmWindow(img.AddrAt(pos-1), m.disasmMaxBytes-m.disasmOverlapBytes()) { - return false - } - idx, found := m.instIndexForAddr(firstAddr) - if found && idx > 0 { - m.disasmCur = idx - 1 - } else { - m.disasmCur = max(0, idx) - } - m.scrollDisasmContext(3) - return true + m.dasm.EnsureViewport(m.dasmEnv(), h) } func (m *Model) stepDisasm(forward bool) bool { - if len(m.disasmInst) == 0 { - return false - } - if forward { - if m.disasmCur < len(m.disasmInst)-1 { - m.disasmCur++ - m.ensureDisasmViewport(m.disasmViewportHeight()) - return true - } - if m.loadDisasmWindowForStep(true) { - m.ensureDisasmViewport(m.disasmViewportHeight()) - return true - } - return false - } - if m.disasmCur > 0 { - m.disasmCur-- - m.ensureDisasmViewport(m.disasmViewportHeight()) - return true - } - if m.loadDisasmWindowForStep(false) { - m.ensureDisasmViewport(m.disasmViewportHeight()) - return true - } - return false + return m.dasm.Step(m.dasmEnv(), forward, m.disasmViewportHeight()) } func (m *Model) moveDisasmPage(forward bool) { - // Advance by one screenful of instructions: the number that fill the scroller - // height at the current top, accounting for multi-line (wrapped) rows. - w := m.disasmRenderWidth() - rowHeight := func(i int) int { return m.disasmInstVisualHeight(i, w) } - steps := pageStep(m.disasmTop, len(m.disasmInst), m.disasmViewportHeight(), rowHeight) - steps = max(1, steps-1) // keep one instruction of context between pages - for i := 0; i < steps; i++ { - if !m.stepDisasm(forward) { - return - } - } + m.dasm.MovePage(m.dasmEnv(), forward, m.disasmViewportHeight(), m.disasmRowHeight(m.disasmRenderWidth())) } func (m *Model) jumpDisasmBoundary(forward bool) { - img := m.file.ExecImage() - if img.Len() == 0 { - return - } - if !forward { - if !m.loadDisasmWindow(img.AddrAt(0), 0) { - return - } - m.disasmCur = 0 - m.disasmTop = 0 - m.renderedDisasmTop = 0 + ok := m.dasm.JumpBoundary(m.dasmEnv(), forward, m.disasmViewportHeight(), m.disasmRowHeight(m.disasmRenderWidth())) + // Only the jump to the start re-attaches the viewport; the end jump keeps a + // detached viewport where the user scrolled it. + if ok && !forward { m.viewportDetached = false - return } - if !m.loadDisasmWindowEnding(img.Len()) { - return - } - m.disasmCur = len(m.disasmInst) - 1 - m.scrollDisasmToBottom() - m.renderedDisasmTop = m.disasmTop -} - -func (m *Model) scrollDisasmToBottom() { - w := m.disasmRenderWidth() - rowHeight := func(i int) int { return m.disasmInstVisualHeight(i, w) } - m.disasmTop = maxViewportTop(len(m.disasmInst), m.disasmViewportHeight(), rowHeight) -} - -// snapshotCursorToHistory updates the current history entry to the precise -// address the cursor is currently parked on. Called before any operation -// that moves us away from the current entry (pushHistory, goBack, goForward), -// so coming back lands on the exact instruction the user was looking at — -// not the window base. -func (m *Model) snapshotCursorToHistory() { - if m.historyPos < 0 || m.historyPos >= len(m.history) { - return - } - if len(m.disasmInst) == 0 { - return - } - m.history[m.historyPos] = m.disasmInst[m.disasmCur].Addr -} - -func (m *Model) pushHistory(addr uint64) { - // Caller is responsible for snapshotting the cursor *before* loading the - // new window — see loadDisasmAt. Doing it here would be too late: the - // disasm has already been re-decoded and the cursor sits at the new - // address, so we'd overwrite the old entry with the new addr and the - // dedup check would silently drop the new push. - if m.historyPos < len(m.history)-1 { - m.history = m.history[:m.historyPos+1] - } - // Don't duplicate the most-recent entry. - if len(m.history) > 0 && m.history[len(m.history)-1] == addr { - m.historyPos = len(m.history) - 1 - return - } - m.history = append(m.history, addr) - if len(m.history) > historyCap { - m.history = m.history[len(m.history)-historyCap:] - } - m.historyPos = len(m.history) - 1 } func (m *Model) goBack() { - if m.historyPos <= 0 { + if m.dasm.HistoryPos <= 0 { m.setStatus("at start of history", false) return } - m.snapshotCursorToHistory() - m.historyPos-- - if m.loadDisasmAtNoHistory(m.history[m.historyPos]) { + m.dasm.SnapshotCursorToHistory() + m.dasm.HistoryPos-- + if m.loadDisasmAtNoHistory(m.dasm.History[m.dasm.HistoryPos]) { m.scrollDisasmContext(10) } - m.setStatus(fmt.Sprintf("back (%d/%d)", m.historyPos+1, len(m.history)), false) + m.setStatus(fmt.Sprintf("back (%d/%d)", m.dasm.HistoryPos+1, len(m.dasm.History)), false) } func (m *Model) goForward() { - if m.historyPos >= len(m.history)-1 { + if m.dasm.HistoryPos >= len(m.dasm.History)-1 { m.setStatus("at end of history", false) return } - m.snapshotCursorToHistory() - m.historyPos++ - if m.loadDisasmAtNoHistory(m.history[m.historyPos]) { + m.dasm.SnapshotCursorToHistory() + m.dasm.HistoryPos++ + if m.loadDisasmAtNoHistory(m.dasm.History[m.dasm.HistoryPos]) { m.scrollDisasmContext(10) } - m.setStatus(fmt.Sprintf("forward (%d/%d)", m.historyPos+1, len(m.history)), false) + m.setStatus(fmt.Sprintf("forward (%d/%d)", m.dasm.HistoryPos+1, len(m.dasm.History)), false) } -// scrollDisasmContext positions the scroll window so the cursor shows with -// context above it: from the start of the containing symbol when that fits in -// the viewport, otherwise linesAbove instructions above the cursor. func (m *Model) scrollDisasmContext(linesAbove int) { - n := len(m.disasmInst) - if n == 0 { - return - } - cur := m.disasmCur - h := m.bodyHeight() - 1 // disasm scroller height (minus the sticky row) - if h < 2 { - m.disasmTop = max(0, cur-linesAbove) - return - } - top := cur - linesAbove - if sym, ok := m.file.SymbolAt(m.disasmInst[cur].Addr); ok { - if si, found := m.instIndexForAddr(sym.Addr); found && si <= cur && cur-si <= h-2 { - // The symbol header line plus its instructions up to the cursor all - // fit above, so start the window at the symbol's first instruction. - top = si - } - } - if top < 0 { - top = 0 - } - m.disasmTop = top + m.dasm.ScrollContext(m.dasmEnv(), linesAbove, m.bodyHeight()-1) } // jumpSymbol moves the cursor to the next (or previous) symbol that lives in // executable code, so the user can step function-by-function through the // disassembly. The jump is recorded in the back/forward history. func (m *Model) jumpSymbol(forward bool) { - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { return } - cur := m.disasmInst[m.disasmCur].Addr + cur := m.dasm.Inst[m.dasm.Cur].Addr inExec := func(s binfile.Symbol) bool { _, ok := m.file.ExecImage().PosForAddr(s.Addr) return ok diff --git a/internal/ui/disasm_render.go b/internal/ui/disasm_render.go index 3bdd3c5..56ea84b 100644 --- a/internal/ui/disasm_render.go +++ b/internal/ui/disasm_render.go @@ -1,143 +1,18 @@ package ui -// Disassembly rendering: instruction text colouring + annotations, the scroller -// with its sticky symbol banner, column layout, and the side-by-side source -// pane. +// Disassembly rendering, shell side: composing the scroller (which lives in +// internal/ui/views/disasm) with the side-by-side source pane, plus the +// annotation vocabulary (targetAnnotation, lmaNote) shared with other views +// through view.Styles. import ( "fmt" - "strings" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/rabarbra/exex/internal/binfile" - "github.com/rabarbra/exex/internal/disasm" - "github.com/rabarbra/exex/internal/dump" + disasmview "github.com/rabarbra/exex/internal/ui/views/disasm" ) -// renderInstText colours an instruction's assembly text, caching the result. -func (m *Model) renderInstText(text string, class disasm.InstClass, instAddr uint64) string { - return m.disasmAsmCache.get(disasmAsmCacheKey{text: text, addr: instAddr, cls: class}, func() string { - return m.renderInstTextStyled(text, class, instAddr) - }) -} - -// disasmAddrSpan marks a run of instruction text (a followable mapped address) -// that should be drawn in a link colour rather than the operand-token colours. -type disasmAddrSpan struct { - start int - end int - style lipgloss.Style -} - -// disasmAddrSpans finds the followable (mapped) address literals in text and the -// link style each should use (intra- vs inter-function). -func (m *Model) disasmAddrSpans(text string, instAddr uint64) []disasmAddrSpan { - if m.file == nil { - return nil - } - curSym, hasCur := m.file.SymbolAt(instAddr) - from := 0 - var spans []disasmAddrSpan - for { - addr, start, end, ok := extractTargetAt(text, from) - if !ok { - return spans - } - if m.file.IsMapped(addr) { - isIntra := hasCur && curSym.Size > 0 && addr >= curSym.Addr && addr < curSym.Addr+curSym.Size - linkSt := m.theme.linkAddrInterStyle - if isIntra { - linkSt = m.theme.linkAddrIntraStyle - } - spans = append(spans, disasmAddrSpan{start: start, end: end, style: linkSt}) - } - from = end - } -} - -func (m *Model) instAnnotation(text string, class disasm.InstClass) string { - annotate := class == disasm.ClassCall || class == disasm.ClassJumpUnc || - class == disasm.ClassJumpCond || isAddrLoadOp(firstToken(text)) - from := 0 - var notes []string - seen := map[string]bool{} - add := func(note string) { - if note == "" || seen[note] { - return - } - seen[note] = true - notes = append(notes, note) - } - for { - addr, _, end, ok := extractTargetAt(text, from) - if !ok { - break - } - if m.file.IsMapped(addr) { - if annotate { - add(m.targetAnnotation(addr)) - } else if sym, ok := m.file.SymbolAt(addr); ok && (sym.Kind == binfile.SymObject || sym.Kind == binfile.SymTLS || sym.Kind == binfile.SymCommon) { - add(m.targetAnnotation(addr)) - } - } - from = end - } - return strings.Join(notes, ", ") -} - -// relocNote describes any relocation whose patched address lies within the -// instruction's bytes [addr, addr+n) — the resolved target of a placeholder -// operand. "" when the instruction carries no relocation. -func (m *Model) relocNote(addr uint64, n int) string { - // Only relocatable objects have relocs against code operands; gating on this - // (a cheap flag) keeps the lazy reloc build from ever firing for a linked - // binary that never needs it. - if n <= 0 || !m.file.IsRelocatable() || !m.file.HasRelocs() { - return "" - } - rs := m.file.RelocsInRange(addr, addr+uint64(n)) - if len(rs) == 0 { - return "" - } - var parts []string - for _, r := range rs { - target := r.Sym - if target == "" { - target = r.Type // no symbol (e.g. a section-relative reloc): name the type - } - if r.HasAddend && r.Addend != 0 { - if r.Addend > 0 { - target += fmt.Sprintf("+0x%x", r.Addend) - } else { - target += fmt.Sprintf("-0x%x", -r.Addend) - } - } - parts = append(parts, "→ "+target) - } - return strings.Join(parts, ", ") -} - -// firstToken returns the mnemonic (first whitespace-delimited token), lowered. -func firstToken(text string) string { - text = strings.TrimSpace(text) - if i := strings.IndexAny(text, " \t"); i >= 0 { - text = text[:i] - } - return strings.ToLower(text) -} - -// isAddrLoadOp reports whether op materialises an address (so its operand is -// worth annotating with the symbol/section it points at). -func isAddrLoadOp(op string) bool { - switch op { - case "lea", "leaq", "leal", "leaw", "adr", "adrp": - return true - } - return false -} - // targetAnnotation labels a follow-able address with whatever the reader is // most likely to want as context: the symbol name (with offset when not at // the symbol's start), or the containing section name when no symbol covers @@ -157,20 +32,20 @@ func (m *Model) targetAnnotation(addr uint64) string { func (m *Model) renderDisasm() string { bodyH := m.bodyHeight() - if m.disasmDecoding { + if m.dasm.Decoding { return m.emptyBody("decoding instructions…") } - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { return m.emptyBody("no disassembly loaded — press g to go to an address, or pick a symbol from view 3") } // The source pane only makes sense when the binary actually carries debug // info; otherwise keep the disasm full-width instead of opening an empty // "no source" pane. - showSrc := m.showSource && m.file.HasDWARF() - if !showSrc && m.sourceFirst && m.hasOpenSourceFile() { + showSrc := m.dasm.ShowSource && m.file.HasDWARF() + if !showSrc && m.dasm.SourceFirst && m.hasOpenSourceFile() { return m.renderSourceText(m.width, bodyH) } - if showSrc && m.sourceFirst && m.hasOpenSourceFile() { + if showSrc && m.dasm.SourceFirst && m.hasOpenSourceFile() { leftW := m.width / 2 rightW := m.width - leftW left := m.renderSourceText(leftW, bodyH) @@ -184,399 +59,62 @@ func (m *Model) renderDisasm() string { rightW = m.width - leftW } - sticky := m.renderStickySymbol(leftW) + sticky := m.dasm.RenderSticky(m.viewContextPtr(), leftW) left := sticky + "\n" + m.renderDisasmScroll(leftW, bodyH-1) if rightW == 0 { return left } - right := m.renderSourcePane(rightW, bodyH) + right := m.dasm.RenderSourcePane(m.viewContextPtr(), rightW, bodyH) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } -// renderStickySymbol always shows which symbol (and offset within it) the -// disasm cursor is currently parked on. Stays pinned regardless of scroll. -func (m *Model) renderStickySymbol(w int) string { - if len(m.disasmInst) == 0 { - return padRight("", w) - } - addr := m.disasmInst[m.disasmCur].Addr - var text string - if sym, ok := m.file.SymbolAt(addr); ok { - off := addr - sym.Addr - if off == 0 { - text = fmt.Sprintf(" %s @ 0x%0*x", m.displaySymbolName(sym), m.file.AddrHexWidth(), addr) - } else { - text = fmt.Sprintf(" %s + 0x%x @ 0x%0*x", m.displaySymbolName(sym), off, m.file.AddrHexWidth(), addr) - } - } else { - text = fmt.Sprintf(" (no symbol) @ 0x%0*x", m.file.AddrHexWidth(), addr) - } - // Relocatable object: the address is synthetic — flag it and show the real - // (section-relative) position. - if m.file.SyntheticAddrs() { - if sec := m.file.SectionAt(addr); sec != nil { - text += fmt.Sprintf(" ~synthetic · %s+0x%x", sec.Name, addr-sec.Addr) - } else { - text += " ~synthetic" +// renderDisasmScroll renders the instruction scroller, choosing the per-row +// address colouring: when the source pane is open (disasm-first), addresses +// are coloured by their source mapping — identical to the source-first disasm +// pane — instead of the intra-function jump-target highlight used in the pure +// disasm view (the view still gives the jump targets priority). +func (m *Model) renderDisasmScroll(w, h int) string { + ctx := m.viewContextPtr() + var addrMap func(addr uint64) (lipgloss.Style, bool) + if m.rightPaneActive() && !m.dasm.SourceFirst && len(m.dasm.Inst) > 0 { + curFile, curLine, _ := m.file.LookupAddrCol(m.dasm.Inst[m.dasm.Cur].Addr) + addrMap = func(addr uint64) (lipgloss.Style, bool) { + return m.dasm.AddrMapStyle(ctx, addr, curFile, curLine), true } } - return m.theme.stickyTitleLine(text, w) + return m.dasm.RenderScroll(ctx, w, h, addrMap) } // disasmRowHeight returns the per-instruction rendered-height function for the -// disasm scroller at render width w (a symbol-start instruction is taller by its -// ":" label rows). Shared by every place that runs the scroll math. +// disasm scroller at render width w. Shared by every place that runs the +// scroll math. func (m *Model) disasmRowHeight(w int) func(int) int { - return func(i int) int { return m.disasmInstVisualHeight(i, w) } -} - -func (m *Model) renderDisasmScroll(w, h int) string { - if h < 1 { - h = 1 - } - rowHeight := m.disasmRowHeight(w) - top := m.visualTopForView(m.disasmCur, m.disasmTop, len(m.disasmInst), h, rowHeight) - m.disasmTop = top - m.renderedDisasmTop = top - - jumpTargets := m.currentIntraJumpTargets() - // When the source pane is open (disasm-first), addresses are coloured by - // their source mapping — identical to the source-first disasm pane — instead - // of the intra-function jump-target highlight used in the pure disasm view. - sourceActive := m.rightPaneActive() && !m.sourceFirst && len(m.disasmInst) > 0 - var curFile string - var curLine int - if sourceActive { - curFile, curLine, _ = m.file.LookupAddrCol(m.disasmInst[m.disasmCur].Addr) - } - var rows []string - for i := top; i < len(m.disasmInst) && len(rows) < h; i++ { - inst := m.disasmInst[i] - // A "═══ .section ═══" banner where an executable section begins (like the - // hex view). Emitted before the symbol label; its row is accounted for in - // disasmInstVisualHeight so scroll/click math stays correct. - if name, ok := m.disasmSectionStart(i); ok { - rows = append(rows, m.disasmSectionBanner(name, w)) - if len(rows) >= h { - break - } - } - if sym, ok := m.file.SymbolAt(inst.Addr); ok && sym.Addr == inst.Addr { - for _, row := range m.disasmLabelRows(m.displaySymbolName(sym), w) { - if len(rows) >= h { - break - } - rows = append(rows, row) - } - if len(rows) >= h { - break - } - } - // The intra-function jump-target highlight takes priority; only addresses - // that aren't a jump target fall back to the source-mapping colour. - var targetStyle *lipgloss.Style - if st, ok := jumpTargets[inst.Addr]; ok { - targetStyle = &st - } else if sourceActive { - st := m.addrMapStyle(inst.Addr, curFile, curLine) - targetStyle = &st - } - for _, row := range m.disasmInstRows(inst, w, i == m.disasmCur, targetStyle) { - if len(rows) >= h { - break - } - rows = append(rows, row) - } - } - return padBodyRows(rows, w, h) + return m.dasm.RowHeight(m.viewContextPtr(), w) } +// disasmRenderWidth is the view's render width, except outside the disasm view +// (a jump can position the window before the user ever switches to it), where +// no pane is open and the scroller would have the screen to itself. func (m *Model) disasmRenderWidth() int { - if m.mode == modeDisasm && m.showSource && m.file.HasDWARF() && !m.sourceFirst { - return m.width / 2 - } - return m.width -} - -func (m *Model) disasmInstVisualHeight(i, w int) int { - if i < 0 || i >= len(m.disasmInst) { - return 1 - } - return m.disasmHeightCache.get(disasmHeightKey{i: i, w: w, wrap: m.wrap}, func() int { - inst := m.disasmInst[i] - h := len(m.disasmInstRows(inst, w, false, nil)) - if _, ok := m.disasmSectionStart(i); ok { - h++ // the "═══ .section ═══" separator row - } - if m.disasmIsSymbolStart(i) { - if sym, ok := m.file.SymbolAt(inst.Addr); ok && sym.Addr == inst.Addr { - h += len(m.disasmLabelRows(m.displaySymbolName(sym), w)) - } else { - h++ - } - } - return h - }) -} - -// instByteWidth is the number of instruction bytes the byte column is sized for: -// the arch's longest encoding, so fixed-length RISC ISAs get a tight column -// instead of x86's wide one. -func (m *Model) instByteWidth() int { - return disasm.MaxInstLen(m.file.Arch()) -} - -// disasmByteColWidth is the printed width of the instruction-byte column, or 0 -// when it is hidden (behavior.hide_disasm_bytes). Compact is 2 hex chars per -// byte; spaced inserts a space between bytes (3 per byte, less the trailing one). -func (m *Model) disasmByteColWidth() int { - if m.cfg.Behavior.HideDisasmBytes { - return 0 - } - n := m.instByteWidth() - if m.cfg.Behavior.SpacedDisasmBytes { - return n*3 - 1 - } - return n * 2 -} - -// disasmBytes renders an instruction's bytes for the byte column, compact or -// spaced per the setting, padded to disasmByteColWidth. -func (m *Model) disasmBytes(b []byte) string { - if m.cfg.Behavior.SpacedDisasmBytes { - return bytesHexSpaced(b, m.instByteWidth()) - } - return bytesHex(b, m.instByteWidth()) -} - -func (m *Model) disasmAsmColumn() int { - col := 1 + 2 + m.file.AddrHexWidth() + 2 // lead space + "0x" + addr + gap - if bw := m.disasmByteColWidth(); bw > 0 { - col += bw + 2 // byte column + gap - } - return col -} - -func (m *Model) disasmAnnotationColumn(w int) int { - // Keep annotations a short, fixed distance after the assembly column so they - // sit close to the code instead of drifting out to mid-pane on a wide, - // source-off disasm view. A long instruction pushes its own annotation - // further right (see disasmInstRows), so this is only the preferred column. - col := m.disasmAsmColumn() + 22 - if hi := w - 12; col > hi { - col = max(m.disasmAsmColumn()+8, hi) - } - return col -} - -func (m *Model) disasmLabelRows(name string, w int) []string { - label := "<" + name + ">:" - if !m.wrap { - return []string{padRight(" "+m.theme.symbolNameStyle.Render(truncateANSI(label, max(1, w-1))), w)} - } - parts := strings.Split(strings.TrimRight(ansi.Wrap(label, max(1, w-1), " \t/.-_:$@<>"), "\n"), "\n") - if len(parts) == 0 { - parts = []string{""} + if m.mode != modeDisasm { + return m.width } - rows := make([]string, 0, len(parts)) - for _, part := range parts { - rows = append(rows, padRight(" "+m.theme.symbolNameStyle.Render(part), w)) - } - return rows + return m.dasm.RenderWidth(m.viewContextPtr()) } -func (m *Model) disasmInstRows(inst disasm.Inst, w int, selected bool, targetStyle *lipgloss.Style) []string { - addrText := fmt.Sprintf("0x%0*x", m.file.AddrHexWidth(), inst.Addr) - addrCol := m.theme.addrStyle.Render(addrText) - if targetStyle != nil { - addrCol = targetStyle.Render(addrText) - } - asmCol := m.disasmAsmColumn() - annCol := m.disasmAnnotationColumn(w) - asm := m.renderInstText(dump.AlignAsm(inst.Text), inst.Class, inst.Addr) - note := "" - if !m.cfg.Behavior.HideAnnotations { - note = m.instAnnotation(inst.Text, inst.Class) - // A relocation landing in this instruction resolves a placeholder operand - // (`call 0x0` → printf) — the key context for object files. Show it first. - if rn := m.relocNote(inst.Addr, len(inst.Bytes)); rn != "" { - if note != "" { - note = rn + ", " + note - } else { - note = rn - } - } - } - - asmFit := fitANSIWidth(asm, max(1, w-asmCol)) - asmEnd := asmCol + lipgloss.Width(asmFit) - - var asmRow string - if m.disasmByteColWidth() > 0 { - asmRow = fmt.Sprintf(" %s %s ", addrCol, m.disasmBytes(inst.Bytes)) + asmFit - } else { - asmRow = fmt.Sprintf(" %s ", addrCol) + asmFit - } - // Highlight only the assembly (prefix + code) of the selected line; the gap, - // the annotation, and any continuation rows stay uncoloured. - if selected { - asmRow = m.selectedDisasmSegment(asmRow) - } - - if note == "" { - return []string{padRight(asmRow, w)} - } - - inlineStart := max(annCol, asmEnd+2) - inlineAvail := w - inlineStart - if inlineAvail > 0 { - first, rest := splitPlainWidth(note, inlineAvail) - if first != "" { - line := asmRow + strings.Repeat(" ", inlineStart-asmEnd) + m.theme.addrStyle.Render(first) - rows := []string{padRight(line, w)} - if rest == "" || !m.wrap { - return rows - } - return append(rows, m.disasmAnnotationContinuationRows(rest, annCol, w)...) - } - } - - // No usable room remains beside the assembly; fall back to continuation rows. - rows := []string{padRight(asmRow, w)} - return append(rows, m.disasmAnnotationContinuationRows(note, annCol, w)...) -} - -func (m *Model) disasmAnnotationContinuationRows(note string, annCol, w int) []string { - belowW := max(1, w-annCol) - var parts []string - if m.wrap { - parts = strings.Split(strings.TrimRight(ansi.Wrap(strings.TrimLeft(note, " "), belowW, " \t/.-_:$@<>,"), "\n"), "\n") - } else { - parts = []string{truncateANSI(note, belowW)} - } - indent := strings.Repeat(" ", annCol) - rows := make([]string, 0, len(parts)) - for _, p := range parts { - rows = append(rows, padRight(indent+m.theme.addrStyle.Render(p), w)) - } - return rows -} - -func splitPlainWidth(s string, w int) (string, string) { - if w <= 0 { - return "", s - } - if lipgloss.Width(s) <= w { - return s, "" - } - used := 0 - for i, r := range s { - rw := lipgloss.Width(string(r)) - if used+rw > w { - return s[:i], s[i:] - } - used += rw - } - return s, "" -} - -func (m *Model) selectedDisasmSegment(s string) string { - sel := m.theme.disasmSelSeq - if sel == "" { - sel = "\x1b[1;48;5;63m" // fallback if the theme didn't derive a sequence - } - return sel + strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+sel) + "\x1b[0m" -} - -func (m *Model) currentIntraJumpTargets() map[uint64]lipgloss.Style { - out := map[uint64]lipgloss.Style{} - if len(m.disasmInst) == 0 || m.disasmCur < 0 || m.disasmCur >= len(m.disasmInst) { - return out - } - cur := m.disasmInst[m.disasmCur] - if cur.Class != disasm.ClassJumpUnc && cur.Class != disasm.ClassJumpCond { - return out - } - curSym, ok := m.file.SymbolAt(cur.Addr) - if !ok || curSym.Size == 0 { - return out - } - from := 0 - for { - addr, _, end, ok := extractTargetAt(cur.Text, from) - if !ok { - return out - } - if addr >= curSym.Addr && addr < curSym.Addr+curSym.Size { - out[addr] = m.theme.linkAddrIntraStyle - } - from = end - } +// disasmColumns is the row geometry for the current file and byte-column +// settings. +func (m *Model) disasmColumns() disasmview.Columns { + return disasmview.ColumnsFor(m.viewContextPtr()) } func (m *Model) ensureSourceForDisasmCursor() bool { - // In source-first mode the source cursor is authoritative — the asm pane - // follows it via syncSourceAsm. Re-deriving srcCur from the disasm cursor - // here would snap the cursor back whenever it moved onto an unmapped - // (shadow) line, which is why "up" sometimes appeared stuck. - if m.sourceFirst && m.srcFile != "" && m.file.SourceLines(m.srcFile) != nil { - return true - } - if len(m.disasmInst) == 0 || m.disasmCur < 0 || m.disasmCur >= len(m.disasmInst) { - return false - } - file, line := m.file.LookupAddr(m.disasmInst[m.disasmCur].Addr) - if file == "" || line == 0 || m.file.SourceLines(file) == nil { - return false - } - if m.srcFile != file { - m.srcFile = file - m.srcCodeLines = m.mappedSourceLines(file) - } - m.srcCur = line - return true + return m.dasm.EnsureSourceForCursor(m.file) } func (m *Model) hasOpenSourceFile() bool { - return m.srcFile != "" && m.file.SourceLines(m.srcFile) != nil -} - -// disasmIsSymbolStart reports whether instruction i begins a symbol (and so is -// preceded by a ":" label line in the scroller). -func (m *Model) disasmIsSymbolStart(i int) bool { - if i < 0 || i >= len(m.disasmInst) { - return false - } - sym, ok := m.file.SymbolAt(m.disasmInst[i].Addr) - return ok && sym.Addr == m.disasmInst[i].Addr -} - -// disasmSectionStart reports whether instruction i begins an executable section -// (and so is preceded by a "═══ name ═══" separator in the scroller). The -// exec-section start addresses are indexed once so this is an O(1) lookup per -// row, not a scan over all sections on every render. -func (m *Model) disasmSectionStart(i int) (string, bool) { - if i < 0 || i >= len(m.disasmInst) { - return "", false - } - if m.execSecStarts == nil { - // Derive banners from the active disasm image's regions, so all-sections - // mode labels data/object-file sections too (not just executable ones). When - // a section's load address (LMA) differs from its virtual address — a - // higher-half kernel, say — note it once here (it's a constant per-section - // offset) rather than on every instruction row. - m.execSecStarts = make(map[uint64]string) - for _, r := range m.file.ExecImage().Regions { - label := r.Name - if sec := m.file.SectionAt(r.Addr); sec != nil { - label += m.lmaNote(sec.PhysAddr) - } - m.execSecStarts[r.Addr] = label - } - } - name, ok := m.execSecStarts[m.disasmInst[i].Addr] - return name, ok + return m.dasm.HasOpenSourceFile(m.viewContextPtr()) } // lmaNote formats a section's load address (LMA) as a banner suffix, or "" when @@ -588,86 +126,3 @@ func (m *Model) lmaNote(phys uint64) string { } return fmt.Sprintf(" LMA 0x%0*x", m.file.AddrHexWidth(), phys) } - -// disasmSectionBanner renders the centred section separator row (matching the -// hex view's "═══ name ═══" banner) to width w. -func (m *Model) disasmSectionBanner(name string, w int) string { - banner := lipgloss.PlaceHorizontal(max(1, w-1), lipgloss.Center, " "+name+" ", - lipgloss.WithWhitespaceChars("=")) - return padRight(m.theme.sectionStyle.Render(banner), w) -} - -func (m *Model) renderSourcePane(w, h int) string { - border := m.theme.paneBorderStyle - inner := w - 1 - if inner < 8 { - inner = w - } - - if len(m.disasmInst) == 0 { - return border.Render(padBody("", inner, h)) - } - addr := m.disasmInst[m.disasmCur].Addr - file, line, col := m.file.LookupAddrCol(addr) - if file == "" { - body := "no source mapping for 0x" + fmt.Sprintf("%x", addr) - return border.Render(padBody(body+"\n", inner, h)) - } - src := m.file.SourceLines(file) - if src == nil { - suffix := fmt.Sprintf(":%d (source file not found)", line) - body := m.theme.viewTitleLine(truncateMiddle(file, max(1, inner-lipgloss.Width(suffix)))+suffix, inner) + "\n" - return border.Render(padBody(body, inner, h)) - } - - hl := m.highlightedSource(file, src) - mapped := m.mappedSourceLines(file) - - suffix := fmt.Sprintf(":%d", line) - if col > 0 { - suffix = fmt.Sprintf(":%d:%d", line, col) - } - loc := truncateMiddle(file, max(1, inner-lipgloss.Width(suffix))) + suffix - half := (h - 1) / 2 - base := line - half - from := base + m.rightScroll - if from < 1 { - from = 1 - } - to := from + h - 2 - if to > len(src) { - to = len(src) - from = to - (h - 2) - if from < 1 { - from = 1 - } - } - // Build the lines directly (vs accumulating into one Builder then splitting it - // back apart in padBody) — fewer per-frame allocations on this hot path. - rows := make([]string, 0, h) - rows = append(rows, m.theme.viewTitleLine(loc, inner)) - for i := from; i <= to; i++ { - var content string - if i-1 >= 0 && i-1 < len(src) { - content = src[i-1] - } - // The code is always shown syntax-highlighted; only the gutter colour - // reflects the mapping (shared srcGutter policy — identical to the - // source-first pane). - if hl != nil && i-1 >= 0 && i-1 < len(hl) { - content = hl[i-1] - } - prefix := m.srcGutter(i, line, mapped, 5) - gutterW := lipgloss.Width(prefix) - rows = append(rows, prefix+fitANSIWidth(content, inner-gutterW)) - // Point carets at every column this source line maps to (a line can map - // at several positions), each in its column colour — same as the - // source-first pane. - if i == line { - if cols := m.sourceLineColumns(file, line); len(cols) > 0 { - rows = append(rows, m.theme.coloredCaretRow(cols, gutterW, inner)) - } - } - } - return border.Render(padBodyRows(rows, inner, h)) -} diff --git a/internal/ui/disasm_render_bench_test.go b/internal/ui/disasm_render_bench_test.go index 6253491..66e74a8 100644 --- a/internal/ui/disasm_render_bench_test.go +++ b/internal/ui/disasm_render_bench_test.go @@ -10,35 +10,62 @@ import ( func BenchmarkDisasmInstRows(b *testing.B) { m := benchmarkDisasmModel() insts := benchmarkDisasmInsts() + ctx := m.viewContextPtr() b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { inst := insts[i%len(insts)] - _ = m.disasmInstRows(inst, 120, i%17 == 0, nil) + _ = m.dasm.InstRows(ctx, inst, 120, i%17 == 0, nil) } } func BenchmarkDisasmScroll(b *testing.B) { m := benchmarkDisasmModel() seed := benchmarkDisasmInsts() - m.disasmInst = make([]disasm.Inst, 0, 1024) + m.dasm.Inst = make([]disasm.Inst, 0, 1024) for i := 0; i < 1024; i++ { inst := seed[i%len(seed)] inst.Addr = 0x1000 + uint64(i*4) - m.disasmInst = append(m.disasmInst, inst) + m.dasm.Inst = append(m.dasm.Inst, inst) } - m.disasmCur = 128 + m.dasm.Cur = 128 m.mode = modeDisasm m.width = 120 m.height = 40 b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - m.disasmTop = i % 128 + m.dasm.Top = i % 128 _ = m.renderDisasmScroll(120, 32) } } +// TestDisasmInstRowCountMatches pins the cheap height counter to the real +// renderer: for every instruction across a range of widths and both wrap modes, +// disasmInstRowCount must equal the number of rows disasmInstRows emits, or the +// scroll/click math (which trusts the counter via the height cache) breaks. +func TestDisasmInstRowCountMatches(t *testing.T) { + m := benchmarkDisasmModel() + m.height = 40 + insts := benchmarkDisasmInsts() + widths := []int{20, 28, 40, 60, 120, 200} + for _, wrap := range []bool{false, true} { + m.wrap = wrap + for _, w := range widths { + m.width = w + // The context snapshots wrap at build time, so rebuild it per setting. + ctx := m.viewContextPtr() + for _, inst := range insts { + want := len(m.dasm.InstRows(ctx, inst, w, false, nil)) + got := m.dasm.InstRowCount(ctx, inst, w) + if got != want { + t.Errorf("wrap=%v w=%d %q: rowCount=%d, rendered %d rows", wrap, w, inst.Text, got, want) + } + } + } + } +} + func benchmarkDisasmModel() *Model { return &Model{ theme: DefaultTheme(), diff --git a/internal/ui/disasm_settle_test.go b/internal/ui/disasm_settle_test.go new file mode 100644 index 0000000..d3685d9 --- /dev/null +++ b/internal/ui/disasm_settle_test.go @@ -0,0 +1,23 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +// disasmReadyFor synthesises the disasmReadyMsg the background decode would +// deliver, so a test can settle the disassembly view synchronously instead of +// waiting on the event loop. Four test files (and the perf harness) drained the +// decode by hand; this is that loop, once. +func disasmReadyFor(m *Model) tea.Msg { + addr := m.dasm.PendingAddr + return disasmReadyMsg{addr: addr, span: m.decodeDisasmAt(addr, m.disasmLeadBytes())} +} + +// settleDisasmDecode applies pending background decodes until none is in flight. +func settleDisasmDecode(model tea.Model) tea.Model { + for { + mm, ok := model.(*Model) + if !ok || !mm.dasm.Decoding { + return model + } + model, _ = mm.Update(disasmReadyFor(mm)) + } +} diff --git a/internal/ui/disasm_source_test.go b/internal/ui/disasm_source_test.go new file mode 100644 index 0000000..7312573 --- /dev/null +++ b/internal/ui/disasm_source_test.go @@ -0,0 +1,120 @@ +package ui + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/rabarbra/exex/internal/binfile" +) + +// buildResolvableDebugSample is buildDebugSample compiled from inside the +// temp directory with a relative source name, so the DWARF comp-dir + name +// join to a path that actually exists on disk — SourceLines must resolve the +// file for any source-pane behavior to be testable. +func buildResolvableDebugSample(t *testing.T) string { + t.Helper() + cc, err := exec.LookPath("gcc") + if err != nil { + cc, err = exec.LookPath("cc") + } + if err != nil { + t.Skip("no C compiler available") + } + dir := t.TempDir() + const code = ` +#include +static int twice(int x) { + return x * 2; +} +int main(int argc, char **argv) { + int value = twice(argc); + printf("%d\n", value); + return value; +} +` + if err := os.WriteFile(filepath.Join(dir, "sample.c"), []byte(code), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(cc, "-g", "-O0", "-o", "sample", "sample.c") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("compile failed: %v\n%s", err, out) + } + return filepath.Join(dir, "sample") +} + +// TestSourceFirstCursorDrivesTheDisasmPane pins the source-first contract: +// opening a file at a mapped line decodes a window and parks the disasm cursor +// on that line's instruction, and stepping to the next mapped line moves it. +// Nothing else asserts the follower actually follows — the wheel test only +// checks the scroll offset — so a dead SyncSourceAsm passed every test. +func TestSourceFirstCursorDrivesTheDisasmPane(t *testing.T) { + path := buildResolvableDebugSample(t) + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + if !f.HasDWARF() { + t.Skip("debug sample has no DWARF") + } + m, err := New(f) + if err != nil { + t.Fatalf("New: %v", err) + } + m.width, m.height = 120, 40 + + var main binfile.Symbol + for _, s := range f.Symbols { + if s.Name == "main" || s.Name == "_main" { + main = s + break + } + } + if main.Name == "" { + t.Skip("debug sample has no main symbol") + } + file, line := f.LookupAddr(main.Addr) + if file == "" || line == 0 { + t.Skip("main has no line mapping") + } + if f.SourceLines(file) == nil { + t.Skipf("DWARF path %q does not resolve on this system", file) + } + + m.openSourceFileInDisasm(file, line) + if m.mode != modeDisasm || !m.dasm.SourceFirst { + t.Fatalf("open did not enter source-first disasm (mode=%v sourceFirst=%v)", m.mode, m.dasm.SourceFirst) + } + if len(m.dasm.Inst) == 0 { + t.Fatal("opening a mapped source line decoded no disasm window") + } + want, ok := f.LineToAddr(file, m.dasm.SrcCur) + if !ok { + t.Fatalf("opened line %d has no mapped address", m.dasm.SrcCur) + } + got, ok := m.dasm.CurAddr() + if !ok || got != want { + t.Fatalf("disasm cursor at %#x, want the line's instruction %#x", got, want) + } + + // Step the source cursor to the next mapped line; the pane must follow. + prevLine := m.dasm.SrcCur + m.gotoMappedLine(true) + if m.dasm.SrcCur == prevLine { + t.Fatal("gotoMappedLine did not advance the source cursor") + } + want2, ok := f.LineToAddr(file, m.dasm.SrcCur) + if !ok { + t.Fatalf("line %d has no mapped address", m.dasm.SrcCur) + } + got2, ok := m.dasm.CurAddr() + if !ok || got2 != want2 { + t.Fatalf("after stepping to line %d the cursor is at %#x, want %#x", m.dasm.SrcCur, got2, want2) + } + if got2 == got { + t.Fatal("the disasm cursor did not move with the source cursor") + } +} diff --git a/internal/ui/disasm_syntax.go b/internal/ui/disasm_syntax.go deleted file mode 100644 index e42ddf0..0000000 --- a/internal/ui/disasm_syntax.go +++ /dev/null @@ -1,160 +0,0 @@ -//go:build !lite - -package ui - -// Default build: Chroma-based assembly syntax highlighting. The `lite` build -// (disasm_syntax_lite.go) swaps in a small theme-driven token highlighter and -// drops Chroma's ~3 MB of embedded lexer/style data. - -import ( - "strings" - - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - - "github.com/rabarbra/exex/internal/arch" - "github.com/rabarbra/exex/internal/disasm" -) - -// disasmAsmLexers caches the Chroma asm lexer per architecture. Keying by arch -// (rather than a single global) means opening a different-arch binary in the same -// process — e.g. a library via the Libs view's "open as primary" — gets the right -// lexer instead of reusing the first file's. -var disasmAsmLexers = map[arch.Arch]chroma.Lexer{} - -// disasmAsmLexerFor returns the asm lexer for a, building and caching it (even -// when nil) on first use. -func disasmAsmLexerFor(a arch.Arch) chroma.Lexer { - if l, ok := disasmAsmLexers[a]; ok { - return l - } - l := newDisasmAsmLexer(a) - disasmAsmLexers[a] = l - return l -} - -func newDisasmAsmLexer(arch arch.Arch) chroma.Lexer { - lexer_names := []string{"ArmAsm", "GAS", "asm", "NASM"} - switch arch { - case disasm.ArchX86, disasm.ArchAMD64, disasm.ArchRISCV64: - lexer_names = append([]string{"GAS"}, lexer_names...) - case disasm.ArchARM64: - lexer_names = append([]string{"ArmAsm"}, lexer_names...) - } - for _, name := range lexer_names { - if lexer := lexers.Get(name); lexer != nil { - return chroma.Coalesce(lexer) - } - } - return nil -} - -// renderInstTextStyled uses Chroma for assembly syntax, overlaying semantic link -// styles on followable address literals. -func (m *Model) renderInstTextStyled(text string, class disasm.InstClass, instAddr uint64) string { - lexer := disasmAsmLexerFor(m.file.Arch()) - if lexer == nil { - return m.renderInstTextFallback(text, class, instAddr) - } - tokens, err := chroma.Tokenise(lexer, nil, text) - if err != nil { - return m.renderInstTextFallback(text, class, instAddr) - } - spans := m.disasmAddrSpans(text, instAddr) - pos := 0 - var b strings.Builder - for _, tok := range tokens { - if tok == chroma.EOF { - break - } - b.WriteString(m.renderDisasmToken(tok, pos, spans)) - pos += len(tok.Value) - } - return b.String() -} - -// renderInstTextFallback colours an instruction by its class and link addresses -// only (no per-token highlighting) — the fallback when no asm lexer matches or -// tokenising fails. -func (m *Model) renderInstTextFallback(text string, class disasm.InstClass, instAddr uint64) string { - classSt := m.theme.styleForClass(class) - from := 0 - var b strings.Builder - spans := m.disasmAddrSpans(text, instAddr) - si := 0 - for from < len(text) { - if si < len(spans) && spans[si].start == from { - b.WriteString(spans[si].style.Render(text[from:spans[si].end])) - from = spans[si].end - si++ - continue - } - next := len(text) - if si < len(spans) { - next = spans[si].start - } - b.WriteString(classSt.Render(text[from:next])) - from = next - } - return b.String() -} - -func (m *Model) renderDisasmToken(tok chroma.Token, pos int, spans []disasmAddrSpan) string { - st := m.disasmTokenStyle(tok.Type) - from := 0 - var b strings.Builder - for _, span := range spans { - lo := max(span.start, pos) - hi := min(span.end, pos+len(tok.Value)) - if hi <= lo { - continue - } - if rel := lo - pos; rel > from { - b.WriteString(st.Render(tok.Value[from:rel])) - } - b.WriteString(span.style.Render(tok.Value[lo-pos : hi-pos])) - from = hi - pos - } - if from < len(tok.Value) { - b.WriteString(st.Render(tok.Value[from:])) - } - return b.String() -} - -func (m *Model) disasmTokenStyle(tt chroma.TokenType) lipgloss.Style { - if m.disasmTokenStyles == nil { - m.disasmTokenStyles = make(map[int]lipgloss.Style) - } - if st, ok := m.disasmTokenStyles[int(tt)]; ok { - return st - } - themeName := sourceSyntaxTheme(m.cfg) - chromaStyle := styles.Get(themeName) - if chromaStyle == nil { - chromaStyle = styles.Fallback - } - st := chromaStyleEntryToLipgloss(chromaStyle.Get(tt), sourceSyntaxForeground(m.cfg)) - m.disasmTokenStyles[int(tt)] = st - return st -} - -func chromaStyleEntryToLipgloss(e chroma.StyleEntry, fallbackFG string) lipgloss.Style { - st := lipgloss.NewStyle() - if e.Colour.IsSet() { - st = st.Foreground(lipgloss.Color(e.Colour.String())) - } else if fallbackFG != "" { - st = st.Foreground(lipgloss.Color(fallbackFG)) - } - if e.Bold == chroma.Yes { - st = st.Bold(true) - } - if e.Italic == chroma.Yes { - st = st.Italic(true) - } - if e.Underline == chroma.Yes { - st = st.Underline(true) - } - return st -} diff --git a/internal/ui/disasm_syntax_lite_test.go b/internal/ui/disasm_syntax_lite_test.go deleted file mode 100644 index 0540b44..0000000 --- a/internal/ui/disasm_syntax_lite_test.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build lite - -package ui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - - "github.com/rabarbra/exex/internal/config" - "github.com/rabarbra/exex/internal/disasm" -) - -func TestLiteDisasmMnemonicCategoriesUseThemeStyles(t *testing.T) { - m := &Model{theme: NewTheme(config.Config{Colors: config.Colors{ - AsmMove: "#010203", - AsmArith: "#040506", - InstructionJumpConditional: "#070809", - }})} - - move := m.renderInstTextStyled("mov %rsp,%rbp", disasm.Classify("mov %rsp,%rbp"), 0) - if plain := ansi.Strip(move); plain != "mov %rsp,%rbp" { - t.Fatalf("move plain text = %q", plain) - } - if !strings.Contains(move, m.theme.asmMoveStyle.Render("mov")) { - t.Fatalf("move mnemonic not styled as move: %q", move) - } - - arith := m.renderInstTextStyled("add $1,%eax", disasm.Classify("add $1,%eax"), 0) - if !strings.Contains(arith, m.theme.asmArithStyle.Render("add")) { - t.Fatalf("arithmetic mnemonic not styled as arithmetic: %q", arith) - } - - jump := m.renderInstTextStyled("je 0x100", disasm.Classify("je 0x100"), 0) - if !strings.Contains(jump, m.theme.classJumpCndStyle.Render("je")) { - t.Fatalf("jump mnemonic not styled as jump: %q", jump) - } -} diff --git a/internal/ui/disasm_syntax_test.go b/internal/ui/disasm_syntax_test.go deleted file mode 100644 index fbbfdfa..0000000 --- a/internal/ui/disasm_syntax_test.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !lite - -package ui - -import ( - "testing" - - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" -) - -func TestDisasmChromaDefaultTokenUsesSyntaxForeground(t *testing.T) { - got := chromaStyleEntryToLipgloss(chroma.StyleEntry{}, "#586e75").Render("x") - want := lipgloss.NewStyle().Foreground(lipgloss.Color("#586e75")).Render("x") - if got != want { - t.Fatalf("default disasm token style = %q, want %q", got, want) - } -} diff --git a/internal/ui/findsearch.go b/internal/ui/findsearch.go new file mode 100644 index 0000000..56de9d0 --- /dev/null +++ b/internal/ui/findsearch.go @@ -0,0 +1,389 @@ +package ui + +// Global value search: given a value picked from the caret (an address, the +// pointer it holds, a symbol, a string, a section), find *every* place that value +// occurs or is referenced across the binary's content — disasm operands that name +// the address, data words that hold it, strings that contain the text, and +// relocations that target it. Results are aggregated into one list, each tagged +// with the view it belongs to and filterable by that view (Tab cycles the facet), +// mirroring the goto portal's layout. The scan runs off the UI goroutine (the +// disasm pass decodes the whole image) and is cancellable, like the xref scan it +// reuses. + +import ( + "bytes" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/bytesearch" + "github.com/rabarbra/exex/internal/ui/layout" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" + findtomodal "github.com/rabarbra/exex/internal/ui/modals/findto" + "github.com/rabarbra/exex/internal/ui/scope" + "github.com/rabarbra/exex/internal/ui/views/strs" +) + +// findMaxPerFacet caps hits collected per source, so a value that appears +// everywhere (0, a common byte) can't blow up memory or the list. +const findMaxPerFacet = 500 + +// findQuery is the resolved value to search for: an address (for disasm refs, +// data words and reloc targets) and/or text (for string and reloc-symbol +// matches). Derived from the chosen seed. +type findQuery struct { + label string // e.g. "_main", "0x1000" — for the modal title + addr uint64 + hasAddr bool + text string + caseSensitive bool // text matching honours case (default off for the l search) +} + +// queryForText interprets a free-text query: a 0x-prefixed literal is an address +// (searched as an address across disasm/data/relocs + the string at it); anything +// else is a literal text/byte search across disasm text, string content, and the +// raw file bytes. Text is not resolved to a symbol — an address is only ever a +// 0x… value, so plain words search content, not the symbol table. +func (m *Model) queryForText(s string) findQuery { + if s == "" { + return findQuery{} + } + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + if a, err := parseAddr(s); err == nil { + return findQuery{label: s, addr: a, hasAddr: true} + } + } + return findQuery{label: s, text: s, caseSensitive: m.findQueryCase} +} + +// StartTextSearch runs the global search for a typed query. It satisfies +// findquery.Host: the prompt hands over raw text, and the shell decides what it +// means (a 0x… literal is an address; anything else is content). +func (m *Model) StartTextSearch(text string, caseSensitive bool) tea.Cmd { + m.findQueryCase = caseSensitive + q := m.queryForText(text) + if !q.hasAddr && q.text == "" { + m.setStatus("type something to search for", true) + return nil + } + return m.launchFindSearch(q) +} + +// queryForSeed resolves a chosen seed into a search query. Seed searches are +// case-sensitive: a seed is an exact value taken from the binary (a symbol name, +// a string), so its case is meaningful. +func (m *Model) queryForSeed(s findtomodal.Seed) findQuery { + q := findQuery{label: s.Preview, caseSensitive: true} + switch s.Scope { + case scope.Addr: + if a, err := parseAddr(s.Value); err == nil { + q.addr, q.hasAddr = a, true + } + q.label = s.Value + default: + // Symbol / String / Section / Library seeds carry text, and an address when + // the seed named a located thing. + q.text = s.Value + q.addr, q.hasAddr = s.Addr, s.HasAddr + } + return q +} + +// StartSearch opens the results modal and launches the per-source scans for the +// selected seed. Each applicable source runs as its own command, so tea.Batch +// executes them concurrently and their hits stream into the list as each +// finishes — the fast data/strings/relocs scans appear almost immediately while +// the disasm decode (the slow one) fills in when it completes. +// +// It satisfies findto.Host. Closing the seed picker first is what keeps the +// picker and the results overlay from both being open (see modalOrder). +func (m *Model) StartSearch(s findtomodal.Seed) tea.Cmd { + m.find.Close() + q := m.queryForSeed(s) + if !q.hasAddr && q.text == "" { + m.setStatus("nothing searchable in that seed", true) + return nil + } + return m.launchFindSearch(q) +} + +// launchFindSearch opens the results overlay and launches the concurrent +// per-source scans for a resolved query — the shared core of the caret-seeded +// (`f`) and free-text (`l`) searches. +// +// disasm/data/relocs each match by address (operand refs / pointer words / +// target) and by text (instruction text / raw bytes / bound symbol); strings by +// text content plus the string at an address. So every source runs whenever the +// query carries either an address or text, which its callers have checked. +func (m *Model) launchFindSearch(q findQuery) tea.Cmd { + m.stopFindSearch() + m.findSeq++ + seq := m.findSeq + done := make(chan struct{}) + m.findCancel = done + + cmds := []tea.Cmd{ + m.backgroundCmd(m.findDisasmCmd(q, seq, done)), + m.backgroundCmd(m.findDataCmd(q, seq, done)), + m.backgroundCmd(m.findStringsCmd(q, seq, done)), + m.backgroundCmd(m.findRelocsCmd(q, seq, done)), + } + m.findResults.Open(q.label, len(cmds)) + m.setStatus("searching for "+q.label+" …", false) + return tea.Batch(cmds...) +} + +// findPartialMsg delivers one source's hits as it finishes, tagged with the facet +// so the modal can mark that view's scan complete (an empty facet whose scan is +// still running shows "searching…", not "no occurrences"). +type findPartialMsg struct { + seq int + facet findresultsmodal.Facet + hits []findresultsmodal.Hit +} + +// textMatcher returns a predicate testing whether a string contains the query's +// text, honouring its case-sensitivity flag (case-insensitive folds against a +// pre-lowered needle with no per-call allocation). Empty text never matches. +func textMatcher(q findQuery) func(string) bool { + if q.text == "" { + return func(string) bool { return false } + } + if q.caseSensitive { + needle := q.text + return func(s string) bool { return strings.Contains(s, needle) } + } + lower := strings.ToLower(q.text) + return func(s string) bool { return layout.ContainsFold(s, lower) } +} + +// bytesMatcher is textMatcher over raw bytes, for the string table (whose entries +// are byte slices into the mapped image, so testing them never allocates a +// string). Both branches are chosen once, up front: the scan used to run the +// case-insensitive fold over every string in the binary and then discard the +// result whenever the query was case-sensitive — which seed searches always are. +func bytesMatcher(q findQuery) func([]byte) bool { + if q.text == "" { + return func([]byte) bool { return false } + } + if q.caseSensitive { + needle := []byte(q.text) + return func(b []byte) bool { return bytes.Contains(b, needle) } + } + lower := strings.ToLower(q.text) + return func(b []byte) bool { return layout.ContainsFoldBytes(b, lower) } +} + +// findDisasmCmd scans the executable image for instructions matching the query: +// operand references to the address (an address query) and/or instruction text +// containing the search text (a free-text query). The slowest source. +func (m *Model) findDisasmCmd(q findQuery, seq int, done <-chan struct{}) tea.Cmd { + svc := m.disasmService() + textMatch := textMatcher(q) + return func() tea.Msg { + match := func(text string) bool { + if q.hasAddr && instReferences(text, q.addr) { + return true + } + return textMatch(text) + } + matches := svc.ScanMatching(match, findMaxPerFacet, done) + hits := make([]findresultsmodal.Hit, 0, len(matches)) + for _, h := range matches { + hits = append(hits, findresultsmodal.Hit{Facet: findresultsmodal.FacetDisasm, Addr: h.Addr, HasAddr: true, Text: h.Text, Sym: h.Sym}) + } + return findPartialMsg{seq: seq, facet: findresultsmodal.FacetDisasm, hits: hits} + } +} + +// findDataCmd finds byte occurrences in the file image: the address as a +// pointer-width little-endian word (an address query) and/or the search text as +// raw ASCII bytes (a free-text query) — the hex/raw facet. +func (m *Model) findDataCmd(q findQuery, seq int, done <-chan struct{}) tea.Cmd { + file := m.file + ptrBytes := m.file.PointerBytes() + sectionAt := m.sectionAtOffset + addrForOff := m.addrForOffset + return func() tea.Msg { + raw := file.Raw() + var hits []findresultsmodal.Hit + // Each pattern to look for, with the note shown for a hit and whether it + // folds ASCII case. fold is a field rather than being re-derived from note: + // it used to be `p.note == "bytes"`, which keyed matching semantics off a + // display string, so renaming the note in the UI would silently change how + // the search matched. + type pat struct { + bytes []byte + note string + fold bool + } + var pats []pat + if q.hasAddr { + pb := make([]byte, ptrBytes) + v := q.addr + for i := range pb { + pb[i] = byte(v) + v >>= 8 + } + // The pointer word is a binary value: folding it would match byte values + // that merely differ by 0x20. + pats = append(pats, pat{bytes: pb, note: "pointer word", fold: false}) + } + if q.text != "" { + pats = append(pats, pat{bytes: []byte(q.text), note: "bytes", fold: !q.caseSensitive}) + } + // findMaxPerFacet caps the facet, not each pattern: an address+text query + // used to run the counter per pattern and could return 2× the documented cap. + for _, p := range pats { + if len(p.bytes) == 0 { + continue + } + for pos := 0; len(hits) < findMaxPerFacet; { + if scanCancelled(done) { + break + } + idx := bytesearch.FindBytesFold(raw, p.bytes, pos, true, p.fold) + if idx < 0 { + break + } + off := uint64(idx) + h := findresultsmodal.Hit{Facet: findresultsmodal.FacetData, Off: off, Text: p.note} + if a, ok := addrForOff(off); ok { + h.Addr, h.HasAddr = a, true + } + if sec := sectionAt(off); sec != nil { + h.Sym = sec.Name + } + hits = append(hits, h) + pos = idx + 1 + } + } + return findPartialMsg{seq: seq, facet: findresultsmodal.FacetData, hits: hits} + } +} + +// findStringsCmd finds strings whose bytes contain the seed text, plus — for an +// address query — the string that lives at the target address (so searching an +// address surfaces the string it is, when it is one). +func (m *Model) findStringsCmd(q findQuery, seq int, done <-chan struct{}) tea.Cmd { + file := m.file + return func() tea.Msg { + var hits []findresultsmodal.Hit + mk := func(e binfile.StringEntry, sym string) findresultsmodal.Hit { + return findresultsmodal.Hit{Facet: findresultsmodal.FacetStrings, Addr: e.Addr, Off: e.Offset, HasAddr: e.HasAddr, Text: strs.Sanitize(file.StringText(e)), Sym: sym} + } + if q.hasAddr { + for _, e := range file.Strings() { + if e.HasAddr && q.addr >= e.Addr && q.addr < e.Addr+uint64(e.Len) { + hits = append(hits, mk(e, "at target")) + break + } + } + } + if q.text != "" { + matches := bytesMatcher(q) + for _, e := range file.Strings() { + if len(hits) >= findMaxPerFacet || scanCancelled(done) { + break + } + if !matches(file.StringBytes(e)) { + continue + } + hits = append(hits, mk(e, e.Section)) + } + } + return findPartialMsg{seq: seq, facet: findresultsmodal.FacetStrings, hits: hits} + } +} + +// findRelocsCmd finds relocations patching the address or binding the symbol. +// +// The symbol test is a substring match honouring the query's case sensitivity, +// like every other facet. It used to be an exact `==` that ignored the flag, so +// searching "malloc" found it in disasm, data and strings but never in the +// relocation that binds `malloc@GLIBC_2.2.5` — the one place the name is most +// likely to be decorated. +func (m *Model) findRelocsCmd(q findQuery, seq int, done <-chan struct{}) tea.Cmd { + file := m.file + symMatch := textMatcher(q) + return func() tea.Msg { + var hits []findresultsmodal.Hit + for _, r := range file.Relocations() { + if len(hits) >= findMaxPerFacet || scanCancelled(done) { + break + } + if !((q.hasAddr && r.Offset == q.addr) || symMatch(r.Sym)) { + continue + } + ctx := r.Type + if r.Sym != "" { + ctx += " " + r.Sym + } + hits = append(hits, findresultsmodal.Hit{Facet: findresultsmodal.FacetRelocs, Addr: r.Offset, HasAddr: r.Offset != 0, Text: ctx, Sym: r.Section}) + } + return findPartialMsg{seq: seq, facet: findresultsmodal.FacetRelocs, hits: hits} + } +} + +// handleFindPartial appends one source's hits as it finishes, keeping the list +// live during the search. When the last source reports, the scan is done. +func (m *Model) handleFindPartial(msg findPartialMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.findSeq || !m.findResults.Running() { + return m, nil // superseded or cancelled + } + if finished := m.findResults.AddHits(msg.facet, msg.hits); finished { + m.findCancel = nil + label := m.findResults.Label() + if n := len(m.findResults.Hits()); n == 0 { + m.setStatus("no occurrences of "+label, false) + } else { + m.setStatus(fmt.Sprintf("%d occurrences of %s", n, label), false) + } + } + return m, nil +} + +func (m *Model) stopFindSearch() { + if m.findCancel != nil { + close(m.findCancel) + m.findCancel = nil + } +} + +// CancelSearch abandons any source scans still in flight. It satisfies +// findresults.Host: the overlay closes itself, and the shell stops the work. +func (m *Model) CancelSearch() { + m.findSeq++ + m.findResults.StopScan() + m.stopFindSearch() +} + +// OpenHit navigates to a hit in the view its facet belongs to. It satisfies +// findresults.Host. +func (m *Model) OpenHit(h findresultsmodal.Hit) { + switch h.Facet { + case findresultsmodal.FacetDisasm: + m.jumpDisasmAtAddr(h.Addr) + case findresultsmodal.FacetData: + if h.HasAddr { + m.openHexAt(h.Addr) + } else { + m.openRawAt(h.Off) + } + case findresultsmodal.FacetStrings: + if h.HasAddr { + m.jumpStringsAtAddr(h.Addr) + } else { + m.jumpStringsAtOffset(h.Off) + } + case findresultsmodal.FacetRelocs: + m.jumpRelocsAtAddr(h.Addr) + default: + if h.HasAddr { + m.gotoAddr(h.Addr) + } else { + m.openRawAt(h.Off) + } + } +} diff --git a/internal/ui/findsearch_match_test.go b/internal/ui/findsearch_match_test.go new file mode 100644 index 0000000..b9cb32d --- /dev/null +++ b/internal/ui/findsearch_match_test.go @@ -0,0 +1,127 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" +) + +// TestTextMatcherCaseSensitivity covers the predicate shared by the disasm and +// relocation facets. +func TestTextMatcherCaseSensitivity(t *testing.T) { + sensitive := textMatcher(findQuery{text: "Malloc", caseSensitive: true}) + if !sensitive("xMallocy") { + t.Error("case-sensitive matcher missed an exact substring") + } + if sensitive("xmallocy") { + t.Error("case-sensitive matcher folded case") + } + + insensitive := textMatcher(findQuery{text: "Malloc"}) + if !insensitive("xmallocy") || !insensitive("xMALLOCy") { + t.Error("case-insensitive matcher failed to fold") + } + + if textMatcher(findQuery{})("anything") { + t.Error("empty query matched") + } +} + +// TestBytesMatcherCaseSensitivity mirrors the above for the strings facet. +// findStringsCmd used to run the fold path over every string in the binary and +// then overwrite the result when the query was case-sensitive — which every +// seed-driven search is. +func TestBytesMatcherCaseSensitivity(t *testing.T) { + sensitive := bytesMatcher(findQuery{text: "Malloc", caseSensitive: true}) + if !sensitive([]byte("xMallocy")) { + t.Error("case-sensitive matcher missed an exact substring") + } + if sensitive([]byte("xmallocy")) { + t.Error("case-sensitive matcher folded case") + } + + insensitive := bytesMatcher(findQuery{text: "Malloc"}) + if !insensitive([]byte("xmallocy")) || !insensitive([]byte("xMALLOCy")) { + t.Error("case-insensitive matcher failed to fold") + } + + if bytesMatcher(findQuery{})([]byte("anything")) { + t.Error("empty query matched") + } +} + +// TestRelocSymbolMatchIsSubstring pins the findRelocsCmd fix. The relocation +// facet compared with `==`, so a search for "malloc" never surfaced the +// relocation binding "malloc@GLIBC_2.2.5" even though the disasm, data and +// strings facets all matched it. +func TestRelocSymbolMatchIsSubstring(t *testing.T) { + match := textMatcher(findQuery{text: "malloc", caseSensitive: true}) + for _, sym := range []string{"malloc", "malloc@GLIBC_2.2.5", "_malloc", "__libc_malloc"} { + if !match(sym) { + t.Errorf("reloc symbol %q did not match query %q", sym, "malloc") + } + } + if match("calloc") { + t.Error("unrelated symbol matched") + } +} + +// collectFacet runs one facet's command and returns its hits. +func collectFacet(t *testing.T, cmd tea.Cmd) []findresultsmodal.Hit { + t.Helper() + msg, ok := cmd().(findPartialMsg) + if !ok { + t.Fatalf("facet command returned %T, want findPartialMsg", msg) + } + return msg.hits +} + +// TestFindDataCapIsPerFacet: findMaxPerFacet counts the facet, not each pattern. +// findDataCmd reset the counter per pattern, so an address+text query could +// return twice the documented cap. +func TestFindDataCapIsPerFacet(t *testing.T) { + path := firstExisting("/bin/ls", "/usr/bin/true", "/bin/cat") + if path == "" { + t.Skip("no system binary available") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + m := newTestModel(t, f) + + // Both patterns run: a zero pointer word and a single NUL byte, each of which + // occurs far more than findMaxPerFacet times in any real binary. + q := findQuery{label: "cap", addr: 0, hasAddr: true, text: "\x00", caseSensitive: true} + hits := collectFacet(t, m.findDataCmd(q, 1, nil)) + if len(hits) > findMaxPerFacet { + t.Errorf("data facet returned %d hits, cap is %d", len(hits), findMaxPerFacet) + } + if len(hits) != findMaxPerFacet { + t.Errorf("expected the scan to saturate the cap, got %d hits", len(hits)) + } +} + +// TestFindDisasmCapIsApplied: the disasm facet never applied findMaxPerFacet at +// all — it returned whatever the (then unbounded) scan collected. +func TestFindDisasmCapIsApplied(t *testing.T) { + path := firstExisting("/bin/ls", "/usr/bin/true", "/bin/cat") + if path == "" { + t.Skip("no system binary available") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + m := newTestModel(t, f) + m.disasmMaxBytes = 1 << 20 + + // A substring present in essentially every instruction stream. + hits := collectFacet(t, m.findDisasmCmd(findQuery{label: "e", text: "e"}, 1, nil)) + if len(hits) > findMaxPerFacet { + t.Errorf("disasm facet returned %d hits, cap is %d", len(hits), findMaxPerFacet) + } +} diff --git a/internal/ui/findto.go b/internal/ui/findto.go new file mode 100644 index 0000000..ce5aedc --- /dev/null +++ b/internal/ui/findto.go @@ -0,0 +1,131 @@ +package ui + +// The shell half of the "Find from here" seed picker (internal/ui/modals/findto). +// +// Reading the caret needs the views and the binary, so building the seeds stays +// here; the overlay only presents them and hands the chosen one back through +// findto.Host.StartSearch. + +import ( + "fmt" + "strings" + + findtomodal "github.com/rabarbra/exex/internal/ui/modals/findto" + "github.com/rabarbra/exex/internal/ui/scope" + "github.com/rabarbra/exex/internal/ui/views/hexraw" +) + +// openFindModal collects the search seeds available at the caret and opens the +// picker. With no seeds it reports rather than opening an empty picker. +func (m *Model) openFindModal() { + if !m.find.Open(m.buildFindSeeds()) { + m.setStatus("nothing under the caret to search for", true) + } +} + +// buildFindSeeds assembles the seeds for the current caret + view. Address-keyed +// seeds need a virtual address; the library seed comes from the Libs view and a +// source-path seed from the Sources view, so f is useful even where there is no +// address under the cursor. +func (m *Model) buildFindSeeds() []findtomodal.Seed { + var out []findtomodal.Seed + seen := map[string]bool{} + add := func(s findtomodal.Seed) { + key := string(rune(s.Scope)) + "\x00" + s.Value + if s.Value == "" || seen[key] { + return + } + seen[key] = true + out = append(out, s) + } + + // View-specific seeds that aren't the caret address itself. + switch m.mode { + case modeDisasm: + // The current instruction's resolved operand target (a call/branch/load + // address) — the thing you most often want to chase from a line of code. + if len(m.dasm.Inst) > 0 && m.dasm.Cur >= 0 && m.dasm.Cur < len(m.dasm.Inst) { + inst := m.dasm.Inst[m.dasm.Cur] + if t, ok := m.followableAddr(inst.Text); ok { + prev := fmt.Sprintf("0x%x", t) + if ann := m.targetAnnotation(t); ann != "" { + prev += " " + ann + } + add(findtomodal.Seed{Label: "Operand", Value: fmt.Sprintf("0x%x", t), Scope: scope.Addr, Preview: prev, Addr: t, HasAddr: true}) + } + } + case modeLibs: + if lib, ok := m.libs.CurrentLib(m.viewContext()); ok { + add(findtomodal.Seed{Label: "Library", Value: lib, Scope: scope.Libs, Preview: lib}) + if base := baseName(lib); base != lib { + add(findtomodal.Seed{Label: "Path", Value: base, Scope: scope.Strings, Preview: base + " (as string)"}) + } + } + case modeSources: + if f, ok := m.sources.CurrentFile(); ok { + add(findtomodal.Seed{Label: "Source", Value: baseName(f), Scope: scope.Strings, Preview: f + " (as string)"}) + } + } + + c, ok := m.caretPos() + if !ok { + return out + } + + // Symbol covering the caret. + if c.hasAddr { + if sym, ok := m.file.SymbolAt(c.addr); ok && sym.Name != "" { + add(findtomodal.Seed{Label: "Symbol", Value: sym.Name, Scope: scope.Symbols, Preview: m.displaySymbolName(sym), Addr: sym.Addr, HasAddr: sym.Addr != 0}) + } + } + // String at the caret (by address or offset). + if s, ok := m.stringAtCaret(c); ok { + txt := m.file.StringText(s) + add(findtomodal.Seed{Label: "String", Value: txt, Scope: scope.Strings, Preview: strconvQuote(txt, 40), Addr: s.Addr, HasAddr: s.HasAddr}) + } + // Section containing the caret. + if c.hasAddr { + if sec := m.file.SectionAt(c.addr); sec != nil && sec.Name != "" { + add(findtomodal.Seed{Label: "Section", Value: sec.Name, Scope: scope.Sections, Preview: sec.Name, Addr: sec.Addr, HasAddr: sec.Addr != 0}) + } + } + // The address itself, when the caret has one. + if c.hasAddr { + add(findtomodal.Seed{Label: "Address", Value: fmt.Sprintf("0x%x", c.addr), Scope: scope.Addr, Preview: fmt.Sprintf("0x%x", c.addr)}) + } + // The pointer the caret's bytes hold. In the Hex/Raw views use the view's own + // aligned read (the exact word the follow-pointer action would use, so f + // mid-pointer matches Enter); elsewhere read by address, or straight from the + // raw bytes at the file offset so a Raw caret over an unmapped header still + // offers a pointer search. + if v, ok := m.caretPointerForFind(c); ok { + prev := fmt.Sprintf("→ 0x%x", v) + if ann := m.targetAnnotation(v); ann != "" { + prev += " " + ann + } + add(findtomodal.Seed{Label: "Pointer", Value: fmt.Sprintf("0x%x", v), Scope: scope.Addr, Preview: prev, Addr: v, HasAddr: m.file.IsMapped(v)}) + } + return out +} + +// caretPointerForFind resolves the pointer to offer as a Find seed: the Hex/Raw +// views' aligned word when active (matching follow-pointer), else the generic +// address/offset read. +func (m *Model) caretPointerForFind(c caret) (uint64, bool) { + switch m.mode { + case modeHex: + return m.byteViews.CaretPointer(m.viewContextPtr(), hexraw.Hex) + case modeRaw: + return m.byteViews.CaretPointer(m.viewContextPtr(), hexraw.Raw) + } + return m.caretPointerAt(c) +} + +// baseName returns the last path element of p (a library install path or a source +// file), for a tidier search seed. +func baseName(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 && i+1 < len(p) { + return p[i+1:] + } + return p +} diff --git a/internal/ui/findto_test.go b/internal/ui/findto_test.go new file mode 100644 index 0000000..20fe553 --- /dev/null +++ b/internal/ui/findto_test.go @@ -0,0 +1,176 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" + findtomodal "github.com/rabarbra/exex/internal/ui/modals/findto" + "testing" +) + +// TestFindModalSeedsAndSearch: f collects search seeds from the caret and, on +// selection, opens the global-search results modal for that seed. +func TestFindModalSeedsAndSearch(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("f") + if !h.m().find.Active() { + t.Fatalf("f did not open the find modal; status=%q", h.m().status) + } + labels := map[string]findtomodal.Seed{} + for _, s := range h.m().find.Seeds() { + labels[s.Label] = s + } + if _, ok := labels["Address"]; !ok { + t.Error("no Address seed from a code caret") + } + // Enter launches the search and opens the results modal (seed picker closes). + cmd := h.m().find.Activate(h.m()) + if h.m().find.Active() { + t.Error("seed picker still open after activate") + } + if !h.m().findResults.Active() || !h.m().findResults.Running() { + t.Fatalf("Enter did not start the search: results=%v running=%v", h.m().findResults.Active(), h.m().findResults.Running()) + } + if cmd == nil { + t.Fatal("no search command returned") + } + if h.m().findResults.Pending() <= 0 { + t.Errorf("findPending = %d, want > 0", h.m().findResults.Pending()) + } + // As each source reports, its hits append and the pending count drops; the last + // one ends the scan. + pending := h.m().findResults.Pending() + for i := 0; i < pending; i++ { + h.m().handleFindPartial(findPartialMsg{seq: h.m().findSeq, hits: []findresultsmodal.Hit{{Facet: findresultsmodal.FacetData, Off: uint64(i)}}}) + } + if h.m().findResults.Running() { + t.Error("scan still running after all sources reported") + } + if len(h.m().findResults.Hits()) != pending { + t.Errorf("got %d hits, want %d (one per source)", len(h.m().findResults.Hits()), pending) + } +} + +// TestFindModalDigitSearch: a seed's number key selects and searches it directly. +func TestFindModalDigitSearch(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("f") + if !h.m().find.Active() || len(h.m().find.Seeds()) == 0 { + t.Skip("no seeds") + } + h.m().find.SetSel(0) + cmd := h.m().find.Activate(h.m()) + if h.m().find.Active() || !h.m().findResults.Active() { + t.Errorf("first seed did not open the results modal: picker=%v results=%v", h.m().find.Active(), h.m().findResults.Active()) + } + if cmd == nil { + t.Fatal("no search command") + } +} + +// TestFindModalCopyValue: c copies the highlighted seed's value (the symbol name, +// address, …) and closes. +func TestFindModalCopyValue(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("f") + if !h.m().find.Active() || len(h.m().find.Seeds()) == 0 { + t.Skip("no seeds") + } + want := h.m().find.Seeds()[h.m().find.Sel()].Value + h.press("c") + if h.m().find.Active() { + t.Error("find modal still open after c") + } + if h.m().lastCopy != want { + t.Errorf("c copied %q, want the selected seed value %q", h.m().lastCopy, want) + } +} + +// TestFindFallbackToGoto: a view with no seeds (Info's has an address caret, so +// use a tree group with none) opens the goto portal directly rather than an empty +// picker. Here we assert the modal is never shown empty. +func TestFindModalNeverEmpty(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeLibs, "8") + h.press("f") + // Either seeds were found (picker open) or it reported "nothing to search" — + // never an empty picker. + if h.m().find.Active() && len(h.m().find.Seeds()) == 0 { + t.Error("find modal opened with no seeds") + } +} + +// TestFindSearchFacetsAndStreaming: hits stream in per source, the facet bar +// filters by view, and a facet still scanning reports "searching" not "empty". +func TestFindSearchFacetsAndStreaming(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("f") + if !h.m().find.Active() { + t.Skip("no seeds") + } + for i, s := range h.m().find.Seeds() { + if s.Label == "Address" { + h.m().find.SetSel(i) + } + } + if cmd := h.m().find.Activate(h.m()); cmd == nil { + t.Fatal("no search cmd") + } + m := h.m() + if !m.findResults.Running() { + t.Fatal("search not running") + } + // Before any source reports, the disasm facet (still scanning) must report as + // scanning, not "no occurrences". + m.findResults.SetFacet(findresultsmodal.FacetDisasm) + if !m.findResults.FacetStillScanning() { + t.Error("disasm facet should be scanning before its source reports") + } + // A data source reports two hits; they appear under the data facet. + m.handleFindPartial(findPartialMsg{seq: m.findSeq, facet: findresultsmodal.FacetData, hits: []findresultsmodal.Hit{ + {Facet: findresultsmodal.FacetData, Off: 0x10}, {Facet: findresultsmodal.FacetData, Off: 0x20}, + }}) + m.findResults.SetFacet(findresultsmodal.FacetData) + if m.findResults.Shown() != 2 { + t.Errorf("data facet shows %d hits, want 2", m.findResults.Shown()) + } + if m.findResults.FacetStillScanning() { + t.Error("data facet reported but still marked scanning") + } +} + +// TestFindQueryFreeText: the `l` global search opens a prompt, interprets a hex +// literal as an address query (all address sources) and free text as a string +// query, then runs the same content scan. +func TestFindQueryFreeText(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("l") + if !h.m().findQueryModal.Active() { + t.Fatal("l did not open the free-text search prompt") + } + for _, r := range "0x1000" { + h.pump(tea.KeyPressMsg(tea.Key{Text: string(r), Code: r})) + } + h.press("enter") + if h.m().findQueryModal.Active() { + t.Error("prompt still open after Enter") + } + if !h.m().findResults.Active() { + t.Fatalf("Enter did not start the search; status=%q", h.m().status) + } + if got := h.m().findResults.Label(); got != "0x1000" { + t.Errorf("results title = %q, want the typed query", got) + } + + // A hex literal is an address query; free text stays a string query. + if q := h.m().queryForText("0x1000"); !q.hasAddr || q.text != "" { + t.Errorf("hex literal: hasAddr=%v text=%q, want an address query", q.hasAddr, q.text) + } + if q := h.m().queryForText("hello"); q.text != "hello" || q.hasAddr { + t.Errorf("free text: text=%q hasAddr=%v, want a string query", q.text, q.hasAddr) + } +} diff --git a/internal/ui/golden_test.go b/internal/ui/golden_test.go new file mode 100644 index 0000000..33506ab --- /dev/null +++ b/internal/ui/golden_test.go @@ -0,0 +1,332 @@ +package ui + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/config" + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/testbin" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" + xrefmodal "github.com/rabarbra/exex/internal/ui/modals/xref" +) + +// Golden-frame tests. +// +// Every view and every modal is rendered at a fixed size against a fixed binary +// (internal/testbin's hand-built ELF, byte-identical on every machine) and the +// full ANSI frame is compared to a committed snapshot. +// +// These exist for the refactor: moving a view or a modal out of the shell must +// produce byte-identical frames. "It renders without panicking and the output is +// non-empty" — which is all the existing smoke tests assert — would not notice a +// row rendered one line off, a lost colour role, or a dropped column. +// +// Regenerate after an intended visual change, and read the diff. Both build +// variants have their own snapshots (see golden_variant_test.go): +// +// go test ./internal/ui/ -run TestGolden -update +// go test -tags lite ./internal/ui/ -run TestGolden -update + +var updateGolden = flag.Bool("update", false, "rewrite the golden frame snapshots") + +const ( + goldenWidth = 100 + goldenHeight = 30 +) + +// goldenFixture writes the fixture to a fixed *relative* path and returns it. +// +// The path matters: the Info view renders it, so a t.TempDir() path would bake a +// random directory into every frame. A relative path under testdata renders the +// same everywhere. The file is regenerated each run, so it is derived state and +// need not be committed. +func goldenFixture(t *testing.T) string { + t.Helper() + const path = "testdata/tiny.elf" + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, testbin.TinyELF64(), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// goldenModel builds the model every frame is rendered from: fixed binary, fixed +// terminal size, fixed theme, no config overrides. +func goldenModel(t *testing.T) *Model { + t.Helper() + f, err := binfile.Open(goldenFixture(t)) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { f.Close() }) + + m, err := New(f) + if err != nil { + t.Fatalf("New: %v", err) + } + m.resize(goldenWidth, goldenHeight) + m.cfg = config.Config{Theme: defaultThemeName} + m.applyThemeChange() + return m +} + +// enterMode switches to a view and drains the commands it returns. +// +// Without the drain, Disasm snapshots as "decoding instructions…" — its decode +// runs as a tea.Cmd — which would make the golden frame for the single most +// complex view a picture of its loading state. +func enterMode(t *testing.T, m *Model, md mode) { + t.Helper() + runModelCmd(t, m, m.switchMode(md)) +} + +// frame renders the current model state the way the event loop does, and returns +// the exact string View() would put on the terminal. +func frame(m *Model) string { + m.viewDirty = true + m.View() + return m.viewCache +} + +// goldenViews renders each top-level view. +func goldenViews(t *testing.T) map[string]string { + t.Helper() + frames := map[string]string{} + for _, md := range []mode{ + modeInfo, modeSections, modeSymbols, modeDisasm, modeHex, + modeLibs, modeRaw, modeStrings, modeSources, modeRelocs, + } { + m := goldenModel(t) + enterMode(t, m, md) + frames["view_"+strings.ToLower(md.String())] = frame(m) + } + return frames +} + +// goldenBackground renders a view with behavior.background on. The panel fill +// covers the whole frame — tab strip and footer included — and it used to stop +// at the body, leaving those two rows on the terminal's own background. +func goldenBackground(t *testing.T) map[string]string { + t.Helper() + m := goldenModel(t) + cfg := config.Config{Theme: defaultThemeName} + cfg.Behavior.Background = true + m.cfg = cfg + m.applyThemeChange() + enterMode(t, m, modeSections) + return map[string]string{"view_sections_background": frame(m)} +} + +// goldenModals renders each overlay. The scan-backed modals (xref, syscalls, +// cpu-features) are opened with injected results rather than by running their +// background scans, so the frames stay deterministic and the test stays fast. +func goldenModals(t *testing.T) map[string]string { + t.Helper() + cases := []struct { + name string + open func(*Model) + }{ + {"help", func(m *Model) { m.help.Open() }}, + {"header", func(m *Model) { m.header.Open() }}, + {"settings", func(m *Model) { m.openSettings() }}, + // Field 14 scrolls the list so its window starts on a group header, which + // is the one row whose leading blank separator is suppressed. Nothing else + // exercises that branch. + {"settings_scrolled", func(m *Model) { + m.openSettings() + m.settings.SetCur(14) + }}, + {"goto", func(m *Model) { m.palette.Open(m) }}, + // A query with results, so the frame covers the badge column, the address + // column and the per-kind row colours — an empty prompt renders only the + // "type to search" hint. + {"goto_results", func(m *Model) { + m.palette.Open(m) + m.palette.SetQuery(m, "t") + }}, + {"search", func(m *Model) { m.openSearch() }}, + {"find_query", func(m *Model) { m.findQueryModal.Open() }}, + {"find_seeds", func(m *Model) { + enterMode(t, m, modeSymbols) + m.openFindModal() + }}, + {"jump", func(m *Model) { + enterMode(t, m, modeDisasm) + m.openJumpModal() + }}, + {"xref", func(m *Model) { + enterMode(t, m, modeDisasm) + m.xrefLabel = "helper" + m.xrefTarget = 0x401020 + m.xref.Open("helper", []xrefmodal.Hit{ + {Addr: 0x401000, Text: "mov rax, 1", Sym: "_start"}, + {Addr: 0x40100e, Text: "call 0x401020", Sym: "_start"}, + }) + }}, + // A populated set, so the frame covers the row layout (name padding, count + // column, first-use address) and the selection bar — not just the + // "no optional features detected" branch an empty set would render. + {"cpufeatures", func(m *Model) { + m.cpufeat.Open(dump.CPUFeatureSet{ + Total: 12345, + Baseline: "x86-64-v3", + Counts: map[string]int{"AVX": 42, "SSE2": 7, "AVX512F": 1, "BMI2": 300}, + FirstUse: map[string]uint64{"AVX": 0x401000, "SSE2": 0x401020, "AVX512F": 0x4010ff, "BMI2": 0x402000}, + }) + }}, + {"cpufeatures_empty", func(m *Model) { m.cpufeat.Open(dump.CPUFeatureSet{Total: 99}) }}, + // The results overlay, with hits from three sources so the frame covers the + // facet bar's per-facet counts, the badge colours, and both location columns + // (an address for disasm/strings, an @offset for a data hit). + {"find_results", func(m *Model) { + m.findResults.Open("_start", 4) + m.findResults.AddHits(findresultsmodal.FacetDisasm, []findresultsmodal.Hit{ + {Facet: findresultsmodal.FacetDisasm, Addr: 0x40100e, HasAddr: true, Text: "call 0x401020", Sym: "_start"}, + }) + m.findResults.AddHits(findresultsmodal.FacetData, []findresultsmodal.Hit{ + {Facet: findresultsmodal.FacetData, Off: 0x3000, Text: "pointer word", Sym: ".data"}, + }) + m.findResults.AddHits(findresultsmodal.FacetStrings, []findresultsmodal.Hit{ + {Facet: findresultsmodal.FacetStrings, Addr: 0x402000, HasAddr: true, Text: "hello world", Sym: ".rodata"}, + }) + m.findResults.AddHits(findresultsmodal.FacetRelocs, nil) // last source: scan finishes + }}, + // Mid-scan, before any source reports: the overlay must say "searching", + // with the running note, not "no occurrences found". + {"find_results_searching", func(m *Model) { m.findResults.Open("_start", 4) }}, + // Long macOS syscall names in the full (+libs) scope: the name column sizes + // itself to the content, so "kdebug_trace_string" is not truncated to + // "kd…e_string" while the origin column beside it sits half empty. + {"syscalls_full_longnames", func(m *Model) { + enterMode(t, m, modeDisasm) + site := func(n int64, name string) dump.SyscallSite { + return dump.SyscallSite{Num: n, HasNum: true, Name: name, Addr: 0x401000, + Origin: "libsystem_kernel.dylib", Text: "svc #0x80"} + } + m.syscalls.SetFullResults([]dump.SyscallSite{ + site(170, "csops_audittoken"), site(173, "waitid"), + site(177, "kdebug_typefilter"), site(178, "kdebug_trace_string"), + site(179, "kdebug_trace64"), site(180, "kdebug_trace"), + }, nil, 2) + m.syscalls.OpenFull() + }}, + {"syscalls", func(m *Model) { + enterMode(t, m, modeDisasm) + m.syscalls.Open([]dump.SyscallSite{ + {Addr: 0x401013, Num: 1, Name: "write", Sym: "_start"}, + }) + }}, + } + + frames := map[string]string{} + for _, tc := range cases { + m := goldenModel(t) + tc.open(m) + frames["modal_"+tc.name] = frame(m) + } + return frames +} + +// TestGoldenFramesAreDeterministic guards the guard: a frame that varies between +// renders of identical state would make every golden comparison a coin flip +// (map iteration order in a render path, a timestamp, a cached-style race). +func TestGoldenFramesAreDeterministic(t *testing.T) { + for name, got := range allGoldenFrames(t) { + if want := allGoldenFrames(t)[name]; got != want { + t.Errorf("%s: frame differs between two renders of identical state", name) + } + } +} + +func allGoldenFrames(t *testing.T) map[string]string { + t.Helper() + frames := goldenViews(t) + for k, v := range goldenModals(t) { + frames[k] = v + } + for k, v := range goldenBackground(t) { + frames[k] = v + } + return frames +} + +func TestGoldenFrames(t *testing.T) { + frames := allGoldenFrames(t) + if len(frames) == 0 { + t.Fatal("no frames rendered") + } + dir := filepath.Join("testdata", goldenVariant) + if *updateGolden { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + for name, got := range frames { + t.Run(name, func(t *testing.T) { + path := filepath.Join(dir, name+".frame") + if *updateGolden { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden frame; run: go test ./internal/ui/ -run TestGolden -update\n%v", err) + } + if got != string(want) { + t.Errorf("frame changed.\n%s", frameDiff(string(want), got)) + } + }) + } +} + +// frameDiff reports the first differing line with its escapes made visible, plus +// the line counts — enough to tell "a row moved" from "a colour changed". +func frameDiff(want, got string) string { + wl, gl := strings.Split(want, "\n"), strings.Split(got, "\n") + var b strings.Builder + if len(wl) != len(gl) { + b.WriteString("line count: want ") + b.WriteString(itoa(len(wl))) + b.WriteString(", got ") + b.WriteString(itoa(len(gl))) + b.WriteString("\n") + } + for i := 0; i < len(wl) && i < len(gl); i++ { + if wl[i] != gl[i] { + b.WriteString("first diff at line ") + b.WriteString(itoa(i + 1)) + b.WriteString("\n want: ") + b.WriteString(visible(wl[i])) + b.WriteString("\n got: ") + b.WriteString(visible(gl[i])) + b.WriteString("\n") + break + } + } + b.WriteString("\nrun `go test ./internal/ui/ -run TestGolden -update` if the change is intended") + return b.String() +} + +// visible replaces ESC with a printable marker so a colour-only diff is legible. +func visible(s string) string { return strings.ReplaceAll(s, "\x1b", "^[") } + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/internal/ui/golden_variant_lite_test.go b/internal/ui/golden_variant_lite_test.go new file mode 100644 index 0000000..08dfff4 --- /dev/null +++ b/internal/ui/golden_variant_lite_test.go @@ -0,0 +1,7 @@ +//go:build lite + +package ui + +// See golden_variant_test.go: the lite build's assembly highlighter produces +// different (still deterministic) frames, snapshotted separately. +const goldenVariant = "golden-lite" diff --git a/internal/ui/golden_variant_test.go b/internal/ui/golden_variant_test.go new file mode 100644 index 0000000..12edc1e --- /dev/null +++ b/internal/ui/golden_variant_test.go @@ -0,0 +1,9 @@ +//go:build !lite + +package ui + +// The default build highlights assembly with Chroma; the lite build swaps in the +// small built-in highlighter. Frames that show disassembly therefore differ by +// design between the two, so each variant gets its own snapshot directory rather +// than one of them losing coverage. +const goldenVariant = "golden" diff --git a/internal/ui/goto.go b/internal/ui/goto.go index c3b2008..1974170 100644 --- a/internal/ui/goto.go +++ b/internal/ui/goto.go @@ -1,149 +1,92 @@ package ui +// The shell half of the "Jump to" command palette (internal/ui/modals/palette), +// plus the address/symbol navigation the rest of the app calls. +// +// Searching needs the symbol table, the demangled-name index, the section list +// and the string corpus; routing a result to a view needs to know what the views +// are. Both stay here. The overlay owns the prompt, the scope selector and the +// result list. + import ( "fmt" "strconv" "strings" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" + palettemodal "github.com/rabarbra/exex/internal/ui/modals/palette" + "github.com/rabarbra/exex/internal/ui/scope" + "github.com/rabarbra/exex/internal/ui/views/strs" ) -// gotoKind tags a palette result so it can be coloured and routed to the right -// view on Enter. -type gotoKind uint8 - -const ( - gkAddr gotoKind = iota - gkSymbol - gkSection - gkString - gkLib -) - -func (k gotoKind) tag() string { - switch k { - case gkSymbol: - return "sym" - case gkSection: - return "sec" - case gkString: - return "str" - case gkLib: - return "lib" - default: - return "addr" - } -} - -// gotoScope selects what the palette searches. -type gotoScope uint8 - -const ( - gsAll gotoScope = iota // symbols + sections + a typed address - gsSymbols // symbols only - gsSections // section names - gsStrings // printable strings (its own scope — the corpus is large) - gsLibs // linked libraries - gsAddr // a raw address (virtual, or physical when toggled) - gsScopeCount -) - -func (s gotoScope) String() string { - switch s { - case gsSymbols: - return "symbols" - case gsSections: - return "sections" - case gsStrings: - return "strings" - case gsLibs: - return "libraries" - case gsAddr: - return "address" - default: - return "all" - } -} - -// gotoTarget is one selectable palette entry, tagged by kind for colour + routing. -type gotoTarget struct { - kind gotoKind - label string - addr uint64 - off uint64 // file offset (sections / strings with no virtual address) - sym binfile.Symbol - hasAddr bool -} - -func (t gotoTarget) isSym() bool { return t.kind == gkSymbol } - // gotoMaxResults bounds how many matches we keep (the list scrolls; the visible // window is sized to the terminal height in renderGotoModal). const gotoMaxResults = 500 -// recomputeGoto rebuilds the palette's result list from the current input and -// scope. Each scope searches its corpus; "all" spans symbols + sections and -// offers a parseable address. Strings/libraries are their own scopes (the string -// corpus is large enough that scanning it on every keystroke must be opt-in). -func (m *Model) recomputeGoto() { - m.gotoResults = m.gotoResults[:0] - m.gotoSel = 0 - m.gotoTop = 0 - val := strings.TrimSpace(m.gotoInput.Value()) +// Search builds the palette's result list. Each scope searches its corpus; "all" +// spans symbols + sections and offers a parseable address. Strings/libraries are +// their own scopes (the string corpus is large enough that scanning it on every +// keystroke must be opt-in). It satisfies palette.Host. +func (m *Model) Search(val string, sc scope.Scope, phys bool) []palettemodal.Target { if val == "" { - return + return nil } needle := strings.ToLower(val) - sc := m.gotoScope + var out []palettemodal.Target - if sc == gsAll || sc == gsAddr { - m.appendAddrTarget(val) + if sc == scope.All || sc == scope.Addr { + out = m.appendAddrTarget(out, val, phys) } - if sc == gsAll || sc == gsSymbols { - m.appendSymbolMatches(needle) + if sc == scope.All || sc == scope.Symbols { + out = m.appendSymbolMatches(out, needle) } - if sc == gsAll || sc == gsSections { - m.appendSectionMatches(needle) + if sc == scope.All || sc == scope.Sections { + out = m.appendSectionMatches(out, needle) } - if sc == gsStrings { - m.appendStringMatches(needle) + if sc == scope.Strings { + out = m.appendStringMatches(out, needle) } - if sc == gsLibs { - m.appendLibMatches(needle) + if sc == scope.Libs { + out = m.appendLibMatches(out, needle) } + return out } +// HasPhysAddrs satisfies palette.Host. +func (m *Model) HasPhysAddrs() bool { return m.file.HasPhysAddrs() } + // appendAddrTarget offers a parseable address, resolving a physical address to // its virtual one when physical mode is on. -func (m *Model) appendAddrTarget(val string) { +func (m *Model) appendAddrTarget(out []palettemodal.Target, val string, phys bool) []palettemodal.Target { a, err := parseAddr(val) if err != nil { - return + return out } label := "address 0x" + strconv.FormatUint(a, 16) - if m.gotoAddrPhys { + if phys { v, ok := m.file.PhysToVirtual(a) if !ok { - return // physical address not in any section's load range + return out // physical address not in any section's load range } label = fmt.Sprintf("physical 0x%x → virtual 0x%x", a, v) a = v } - m.gotoResults = append(m.gotoResults, gotoTarget{kind: gkAddr, label: label, addr: a, hasAddr: true}) + return append(out, palettemodal.Target{Kind: palettemodal.KindAddr, Label: label, Addr: a, HasAddr: true}) } // appendSymbolMatches ranks symbols (raw + demangled name) exact → prefix → // substring and appends bounded buckets. The palette never shows more than // gotoMaxResults entries, so keep at most that many per rank instead of // collecting/sorting every match on large symbol tables for every keystroke. -func (m *Model) appendSymbolMatches(needle string) { - remain := gotoMaxResults - len(m.gotoResults) +func (m *Model) appendSymbolMatches(out []palettemodal.Target, needle string) []palettemodal.Target { + remain := gotoMaxResults - len(out) if remain <= 0 { - return + return out } lowerName, lowerDem := m.file.LowerNames() - var exact, prefix, substr []gotoTarget - add := func(dst *[]gotoTarget, t gotoTarget) { + var exact, prefix, substr []palettemodal.Target + add := func(dst *[]palettemodal.Target, t palettemodal.Target) { if len(*dst) < remain { *dst = append(*dst, t) } @@ -157,7 +100,7 @@ func (m *Model) appendSymbolMatches(needle string) { if !strings.Contains(name, needle) && (dem == "" || !strings.Contains(dem, needle)) { continue } - t := gotoTarget{kind: gkSymbol, label: s.Display(), addr: s.Addr, sym: s, hasAddr: true} + t := palettemodal.Target{Kind: palettemodal.KindSymbol, Label: s.Display(), Addr: s.Addr, Sym: s, HasAddr: true} switch { case name == needle || dem == needle: add(&exact, t) @@ -171,115 +114,107 @@ func (m *Model) appendSymbolMatches(needle string) { } } flush: - for _, bucket := range [][]gotoTarget{exact, prefix, substr} { + for _, bucket := range [][]palettemodal.Target{exact, prefix, substr} { for _, t := range bucket { - if len(m.gotoResults) >= gotoMaxResults { - return + if len(out) >= gotoMaxResults { + return out } - m.gotoResults = append(m.gotoResults, t) + out = append(out, t) } } + return out } // appendSectionMatches matches section names. -func (m *Model) appendSectionMatches(needle string) { +func (m *Model) appendSectionMatches(out []palettemodal.Target, needle string) []palettemodal.Target { for i := range m.file.Sections { s := m.file.Sections[i] - if s.Size == 0 || !containsFold(s.Name, needle) { + if s.Size == 0 || !layout.ContainsFold(s.Name, needle) { continue } - m.gotoResults = append(m.gotoResults, gotoTarget{ - kind: gkSection, label: s.Name, addr: s.Addr, off: s.Offset, hasAddr: s.Addr != 0, + out = append(out, palettemodal.Target{ + Kind: palettemodal.KindSection, Label: s.Name, Addr: s.Addr, Off: s.Offset, HasAddr: s.Addr != 0, }) - if len(m.gotoResults) >= gotoMaxResults { - return + if len(out) >= gotoMaxResults { + return out } } + return out } // appendStringMatches matches printable strings (zero-copy over the file image). -func (m *Model) appendStringMatches(needle string) { +func (m *Model) appendStringMatches(out []palettemodal.Target, needle string) []palettemodal.Target { for _, e := range m.file.Strings() { - if !containsFoldBytes(m.file.StringBytes(e), needle) { + if !layout.ContainsFoldBytes(m.file.StringBytes(e), needle) { continue } - m.gotoResults = append(m.gotoResults, gotoTarget{ - kind: gkString, label: sanitizeString(m.file.StringText(e)), addr: e.Addr, off: e.Offset, hasAddr: e.HasAddr, + out = append(out, palettemodal.Target{ + Kind: palettemodal.KindString, Label: strs.Sanitize(m.file.StringText(e)), Addr: e.Addr, Off: e.Offset, HasAddr: e.HasAddr, }) - if len(m.gotoResults) >= gotoMaxResults { - return + if len(out) >= gotoMaxResults { + return out } } + return out } // appendLibMatches matches linked-library names. -func (m *Model) appendLibMatches(needle string) { +func (m *Model) appendLibMatches(out []palettemodal.Target, needle string) []palettemodal.Target { if m.file.Info == nil { - return + return out } for _, lib := range m.file.Info.DynamicLibs { - if containsFold(lib, needle) { - m.gotoResults = append(m.gotoResults, gotoTarget{kind: gkLib, label: lib}) + if layout.ContainsFold(lib, needle) { + out = append(out, palettemodal.Target{Kind: palettemodal.KindLib, Label: lib}) } - if len(m.gotoResults) >= gotoMaxResults { - return + if len(out) >= gotoMaxResults { + return out } } + return out } -// activateGoto acts on the highlighted result, routing it to the natural view for -// its kind; with no results it falls back to a bare address parse. -func (m *Model) activateGoto() { - if m.gotoSel < 0 || m.gotoSel >= len(m.gotoResults) { - if a, err := parseAddr(strings.TrimSpace(m.gotoInput.Value())); err == nil { +// Activate acts on the highlighted result, routing it to the natural view for its +// kind; with no results it falls back to a bare address parse. It satisfies +// palette.Host. +func (m *Model) Activate(t palettemodal.Target, hasSel bool, typed string) { + if !hasSel { + if a, err := parseAddr(typed); err == nil { m.gotoAddr(a) } else { m.setStatus("nothing to go to", true) } return } - t := m.gotoResults[m.gotoSel] // In the Sources view, an address/symbol target navigates by source line. - if m.mode == modeSources && (t.kind == gkAddr || t.kind == gkSymbol) { - m.openSourceForAddr(t.addr) + if m.mode == modeSources && (t.Kind == palettemodal.KindAddr || t.Kind == palettemodal.KindSymbol) { + m.openSourceForAddr(t.Addr) return } - switch t.kind { - case gkSymbol: - m.openSymbol(t.sym) - case gkSection, gkString: - if t.hasAddr { - m.gotoAddr(t.addr) + switch t.Kind { + case palettemodal.KindSymbol: + m.openSymbol(t.Sym) + case palettemodal.KindSection, palettemodal.KindString: + if t.HasAddr { + m.gotoAddr(t.Addr) } else { - m.openRawAt(t.off) + m.openRawAt(t.Off) } - case gkLib: - m.openLibsFiltered(t.label) + case palettemodal.KindLib: + m.openLibsFiltered(t.Label) default: - m.gotoAddr(t.addr) + m.gotoAddr(t.Addr) } } // openLibsFiltered shows the Libraries view filtered to lib (where the user can // open it as primary). func (m *Model) openLibsFiltered(lib string) { - m.libsFilter.SetValue(lib) + m.libs.Filter.SetValue(lib) m.setMode(modeLibs) m.setStatus("library: "+lib+" (press o to open it)", false) } -// gotoSelectionAddr returns the address of the highlighted result, falling back -// to a bare address typed into the prompt. -func (m *Model) gotoSelectionAddr() (uint64, bool) { - if m.gotoSel >= 0 && m.gotoSel < len(m.gotoResults) { - return m.gotoResults[m.gotoSel].addr, true - } - if a, err := parseAddr(strings.TrimSpace(m.gotoInput.Value())); err == nil { - return a, true - } - return 0, false -} - // openSourceForAddr opens the Sources view at the source location that addr // maps to. func (m *Model) openSourceForAddr(addr uint64) { @@ -288,20 +223,8 @@ func (m *Model) openSourceForAddr(addr uint64) { m.setStatus(fmt.Sprintf("no source mapping for 0x%x", addr), true) return } - m.ensureSources() - m.openSourceFile(file, line) -} - -// closeGoto dismisses the goto modal and resets its transient state. -func (m *Model) closeGoto() { - m.gotoActive = false - m.gotoInput.Blur() - m.gotoInput.SetValue("") - m.gotoResults = m.gotoResults[:0] - m.gotoSel = 0 - m.gotoTop = 0 - m.gotoScope = gsAll - m.gotoAddrPhys = false + m.sources.Ensure(m.viewContext()) + m.openSourceFileInDisasm(file, line) } // gotoTargetString navigates to a startup goto argument: an explicit address @@ -380,13 +303,13 @@ func (m *Model) resolveSymbolGoto(s string) (best binfile.Symbol, count int, exa // openSymbolsFiltered shows the Symbols view with q applied as the live filter. func (m *Model) openSymbolsFiltered(q string) { - m.symbolsFilter.SetValue(q) - m.recomputeSymbols() - m.symbolsCur = 0 - m.symbolsTop = 0 + m.symbols.Filter.SetValue(q) + m.symbols.Recompute(m.viewContext()) + m.symbols.Cur = 0 + m.symbols.Top = 0 m.viewportDetached = false m.setMode(modeSymbols) - m.setStatus(fmt.Sprintf("%d symbols match %q", len(m.symbolsFiltered), q), false) + m.setStatus(fmt.Sprintf("%d symbols match %q", len(m.symbols.Filtered), q), false) } // gotoAddr jumps to a virtual address: disasm if it lands in executable code, diff --git a/internal/ui/goto_test.go b/internal/ui/goto_test.go index 2bc68fc..40ef966 100644 --- a/internal/ui/goto_test.go +++ b/internal/ui/goto_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/rabarbra/exex/internal/binfile" + palettemodal "github.com/rabarbra/exex/internal/ui/modals/palette" ) func TestResolveSymbolGoto(t *testing.T) { @@ -34,14 +35,14 @@ func TestAppendSymbolMatchesRanksBeforeTableOrder(t *testing.T) { {Name: "target_helper", Addr: 0x2000}, {Name: "target", Addr: 0x1000}, }}} - m.appendSymbolMatches("target") - if got := len(m.gotoResults); got != 3 { - t.Fatalf("matches = %d, want 3", got) + got := m.appendSymbolMatches(nil, "target") + if len(got) != 3 { + t.Fatalf("matches = %d, want 3", len(got)) } want := []string{"target", "target_helper", "zzz_target"} for i, w := range want { - if got := m.gotoResults[i].label; got != w { - t.Fatalf("result[%d] = %q, want %q", i, got, w) + if got[i].Label != w { + t.Fatalf("result[%d] = %q, want %q", i, got[i].Label, w) } } } @@ -52,36 +53,35 @@ func TestAppendSymbolMatchesFlushesWhenExactBucketFillsCap(t *testing.T) { {Name: "needle", Addr: 0x2000}, {Name: "needle", Addr: 0x3000}, }}} - m.gotoResults = make([]gotoTarget, gotoMaxResults-2) - m.appendSymbolMatches("needle") - if got := len(m.gotoResults); got != gotoMaxResults { - t.Fatalf("matches after bounded append = %d, want %d", got, gotoMaxResults) + seeded := make([]palettemodal.Target, gotoMaxResults-2) + got := m.appendSymbolMatches(seeded, "needle") + if len(got) != gotoMaxResults { + t.Fatalf("matches after bounded append = %d, want %d", len(got), gotoMaxResults) } for i := gotoMaxResults - 2; i < gotoMaxResults; i++ { - if got := m.gotoResults[i].label; got != "needle" { - t.Fatalf("result[%d] = %q, want needle", i, got) + if got[i].Label != "needle" { + t.Fatalf("result[%d] = %q, want needle", i, got[i].Label) } } } func TestStartupGotoMultipleMatchesOpensSymbols(t *testing.T) { m := &Model{ - theme: DefaultTheme(), - layoutState: layoutState{width: 120, height: 30}, - file: &binfile.File{Symbols: []binfile.Symbol{{Name: "do_thing", Addr: 0x1000}, {Name: "do_other", Addr: 0x2000}}}, - symbolsState: symbolsState{}, + theme: DefaultTheme(), + layoutState: layoutState{width: 120, height: 30}, + file: &binfile.File{Symbols: []binfile.Symbol{{Name: "do_thing", Addr: 0x1000}, {Name: "do_other", Addr: 0x2000}}}, } - m.symbolsFilter = newPromptInput("", "/ ") - m.recomputeSymbols() + m.symbols.Filter = newPromptInput("", "/ ") + m.symbols.Recompute(m.viewContext()) m.gotoTargetString("do_") // two substring matches, no exact if m.mode != modeSymbols { t.Fatalf("multiple matches should open Symbols view, got mode %v", m.mode) } - if got := m.symbolsFilter.Value(); got != "do_" { + if got := m.symbols.Filter.Value(); got != "do_" { t.Fatalf("filter = %q, want do_", got) } - if len(m.symbolsFiltered) != 2 { - t.Fatalf("filtered count = %d, want 2", len(m.symbolsFiltered)) + if len(m.symbols.Filtered) != 2 { + t.Fatalf("filtered count = %d, want 2", len(m.symbols.Filtered)) } } diff --git a/internal/ui/hexraw_cache_test.go b/internal/ui/hexraw_cache_test.go new file mode 100644 index 0000000..a547d9b --- /dev/null +++ b/internal/ui/hexraw_cache_test.go @@ -0,0 +1,61 @@ +package ui + +import ( + "strings" + "testing" +) + +// byteRows is the hex/raw body without its banner line — i.e. exactly the rows +// the memo serves. +func byteRows(m *Model) string { + lines := strings.Split(m.current().body(), "\n") + if len(lines) > 1 { + lines = lines[1:] + } + return strings.Join(lines, "\n") +} + +// TestHexRowsRestyleOnAThemeChange guards the hex/raw row memo. The rows bake in +// the byte palette and the address colour, so a theme change must drop them — a +// golden frame renders once and so can never catch a cache serving pre-change +// rows. +func TestHexRowsRestyleOnAThemeChange(t *testing.T) { + for _, md := range []mode{modeHex, modeRaw} { + t.Run(md.String(), func(t *testing.T) { + m := goldenModel(t) + enterMode(t, m, md) + // Only the byte rows. The whole frame won't do — a theme change restyles + // the tabs and footer — and neither will the whole body, whose first line + // is a banner rendered fresh every frame. Either would pass with stale + // rows underneath. + before := byteRows(m) + + // Any theme with a different palette will do. + other := "solarized-light" + if m.cfg.Theme == other { + other = "nord" + } + m.cfg.Theme = other + m.applyThemeChange() + + if after := byteRows(m); after == before { + t.Fatalf("%v rows rendered identically after a theme change — the row cache served stale rows", md) + } + }) + } +} + +// TestHexRowsFollowTheCaret pins that the memo is keyed on where the caret is: +// moving it must redraw the row it entered (and the one it left), not reuse them. +func TestHexRowsFollowTheCaret(t *testing.T) { + m := goldenModel(t) + enterMode(t, m, modeHex) + before := frame(m) + + for range 4 { // move down a row's worth of bytes + m.handleKey(keyPress("down")) + } + if after := frame(m); after == before { + t.Fatal("the hex frame did not change after moving the caret — rows were served from the cache") + } +} diff --git a/internal/ui/infoopen.go b/internal/ui/infoopen.go new file mode 100644 index 0000000..eff5dcf --- /dev/null +++ b/internal/ui/infoopen.go @@ -0,0 +1,74 @@ +package ui + +// Shell-side Info actions. The overview page itself lives in +// internal/ui/views/info; archive-member browsing (archive.go) and fat-arch +// switching stay in the shell because they replace the whole Model — the same +// boundary as libopen.go's open-as-primary. + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" +) + +func (m *Model) updateInfo(msg tea.KeyMsg, key string) (tea.Model, tea.Cmd) { + if m.isArchive() && m.infoMembers { + return m.updateMembersList(key) + } + if key == "t" { + // For a static library, `t`/`tab` opens the members list (doc #22). For a + // fat Mach-O it toggles the next architecture slice (doc #27). Both mirror + // the toggle role `t` plays in the other views. + if m.isArchive() { + m.enterMembersList() + return m, nil + } + if len(m.file.FatArches) > 1 { + return m.switchFatArch() + } + return m, nil + } + return m, m.info.Update(m.viewContext(), m, msg, key) +} + +func (m *Model) renderInfo() string { + if m.isArchive() && m.infoMembers { + return m.renderMembersList() + } + return m.info.Render(m.viewContext()) +} + +// switchFatArch re-opens the binary at the next architecture slice of a fat +// Mach-O, returning a fresh model for it. The previous mapping is retired after +// its model-owned background commands have physically completed. +func (m *Model) switchFatArch() (tea.Model, tea.Cmd) { + arches := m.file.FatArches + next := arches[0] + for i, a := range arches { + if a == m.file.FatArch { + next = arches[(i+1)%len(arches)] + break + } + } + opts := []binfile.Option{binfile.WithArch(next)} + if dp := m.file.DebugPath(); dp != "" { + opts = append(opts, binfile.WithDebugPath(dp)) + } + f, err := binfile.Open(m.file.Path, opts...) + if err != nil { + m.setStatus("switch arch: "+err.Error(), true) + return m, nil + } + nm, err := New(f, Options{Config: &m.cfg}) + if err != nil { + f.Close() + m.setStatus("switch arch: "+err.Error(), true) + return m, nil + } + nm.width, nm.height = m.width, m.height + nm.fileStack = append([]*Model(nil), m.fileStack...) + nm.fileLabel = m.fileLabel + nm.setStatus("architecture: "+next, false) + m.retireFile() + return nm, nm.Init() +} diff --git a/internal/ui/view_info_test.go b/internal/ui/infoopen_test.go similarity index 83% rename from internal/ui/view_info_test.go rename to internal/ui/infoopen_test.go index 26db60d..c205504 100644 --- a/internal/ui/view_info_test.go +++ b/internal/ui/infoopen_test.go @@ -6,6 +6,7 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/config" + "github.com/rabarbra/exex/internal/ui/layout" ) func TestInfoViewUsesThemeBodyForeground(t *testing.T) { @@ -22,7 +23,7 @@ func TestInfoViewUsesThemeBodyForeground(t *testing.T) { } out := m.renderInfo() - want := renderStyle("64-bit, little-endian", 0, m.theme.tableRowStyle) + want := layout.RenderStyle("64-bit, little-endian", 0, m.theme.tableRowStyle) if !strings.Contains(out, want) { t.Fatalf("info value does not use themed body foreground: %q", out) } diff --git a/internal/ui/init_bench_test.go b/internal/ui/init_bench_test.go new file mode 100644 index 0000000..8dac579 --- /dev/null +++ b/internal/ui/init_bench_test.go @@ -0,0 +1,62 @@ +package ui + +import ( + "os" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/config" +) + +func settleBenchmarkProgram(model tea.Model, initial tea.Cmd) tea.Model { + results := make(chan tea.Msg) + active := 0 + start := func(cmd tea.Cmd) { + if cmd == nil { + return + } + active++ + go func() { results <- cmd() }() + } + start(initial) + for active > 0 { + msg := <-results + active-- + if batch, ok := msg.(tea.BatchMsg); ok { + for _, cmd := range batch { + start(cmd) + } + continue + } + var next tea.Cmd + model, next = model.Update(msg) + start(next) + } + return model +} + +func BenchmarkInitSettled(b *testing.B) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + b.ReportAllocs() + for range b.N { + b.StopTimer() + f, err := binfile.Open(path) + if err != nil { + b.Fatal(err) + } + m, err := New(f, Options{Config: &config.Config{}}) + if err != nil { + f.Close() + b.Fatal(err) + } + b.StartTimer() + settleBenchmarkProgram(m, m.Init()) + b.StopTimer() + f.Close() + } +} diff --git a/internal/ui/jump.go b/internal/ui/jump.go index fc54aa5..3dac67d 100644 --- a/internal/ui/jump.go +++ b/internal/ui/jump.go @@ -5,6 +5,8 @@ package ui // resolves the address (or file offset) and switches view, reporting via the // status line when the target can't be shown. +import "fmt" + // jumpDisasmAtAddr opens the disassembly view at addr, or reports why it can't. // When the target isn't in the current (exec-only) image but a section with // content covers it — e.g. a kernel/multiboot section that isn't flagged @@ -52,6 +54,82 @@ func (m *Model) jumpRawAtAddr(addr uint64) { m.openRawAt(off) } +// jumpSymbolsAtAddr opens the Symbols view located on the symbol covering addr +// (the cross-view "open caret in Symbols" jump), filtered to its name so it is +// the visible selection. +func (m *Model) jumpSymbolsAtAddr(addr uint64) { + sym, ok := m.file.SymbolAt(addr) + if !ok || sym.Name == "" { + m.setStatus(fmt.Sprintf("no symbol at 0x%x", addr), true) + return + } + m.symbols.FilterByName(m.viewContext(), sym.Name) + m.setMode(modeSymbols) + m.setStatus("symbol "+m.displaySymbolName(sym)+" — Esc clears filter", false) +} + +// jumpSectionsAtAddr opens the Sections view with the section containing addr +// selected. +func (m *Model) jumpSectionsAtAddr(addr uint64) { + if !m.sections.SelectByAddr(addr) { + m.setStatus(fmt.Sprintf("no section contains 0x%x", addr), true) + return + } + m.setMode(modeSections) +} + +// jumpRelocsAtAddr opens the Relocs view with the relocation patching addr +// selected, or reports when there is none. +func (m *Model) jumpRelocsAtAddr(addr uint64) { + if !m.file.HasRelocs() { + m.setStatus("no relocations in this binary", true) + return + } + if !m.relocs.SelectByAddr(m.viewContext(), addr) { + m.setStatus(fmt.Sprintf("no relocation patches 0x%x", addr), true) + return + } + m.setMode(modeRelocs) +} + +// jumpStringsAtAddr opens the Strings view with the string covering addr +// selected, or reports when there is none. +func (m *Model) jumpStringsAtAddr(addr uint64) { + if !m.strs.SelectByAddr(m.viewContext(), addr) { + m.setStatus(fmt.Sprintf("no string at 0x%x", addr), true) + return + } + m.setMode(modeStrings) +} + +// jumpStringsAtOffset opens the Strings view with the string covering file offset +// off selected — the offset-only counterpart used from the Raw view. +func (m *Model) jumpStringsAtOffset(off uint64) { + if !m.strs.SelectByOffset(m.viewContext(), off) { + m.setStatus(fmt.Sprintf("no string at file offset 0x%x", off), true) + return + } + m.setMode(modeStrings) +} + +// lowestVirtAddr returns the lowest mapped (allocated, non-zero) virtual address +// in the image — the natural "start" when there is no entry point. ok is false +// for a binary with no allocated sections (e.g. a pure object file). +func (m *Model) lowestVirtAddr() (uint64, bool) { + var best uint64 + ok := false + for i := range m.file.Sections { + s := &m.file.Sections[i] + if !s.Alloc || s.Addr == 0 || s.Size == 0 { + continue + } + if !ok || s.Addr < best { + best, ok = s.Addr, true + } + } + return best, ok +} + // fileOffsetForAddr maps a virtual address to its file offset using the section // whose file bytes cover it. Returns false for addresses outside any // file-backed section (e.g. .bss). diff --git a/internal/ui/jumpto.go b/internal/ui/jumpto.go new file mode 100644 index 0000000..ac4bd54 --- /dev/null +++ b/internal/ui/jumpto.go @@ -0,0 +1,420 @@ +package ui + +// The caret machinery, and the shell half of the "open caret position in…" +// overlay (internal/ui/modals/jumpto). +// +// What lives here is everything that needs to know about views and the binary: +// where the caret is (`caret`, caretPos), what it points at (stringAtCaret, +// caretPointerAt, relocPreviewAt, caretContextLine), which views could show it +// (buildJumpTargets), and how to actually go there (OpenCaretIn). The caret +// helpers are shared with the Find overlay and the hex views, so they are not +// the jump overlay's to own. + +import ( + "fmt" + "strings" + + "github.com/rabarbra/exex/internal/binfile" + jumptomodal "github.com/rabarbra/exex/internal/ui/modals/jumpto" +) + +// modeDigit is the view-switch digit for a mode (matching defaultKeyMap), shown +// as a badge in the modal and usable as a shortcut to jump straight to that view. +func modeDigit(md mode) string { + switch md { + case modeInfo: + return "1" + case modeSections: + return "2" + case modeSymbols: + return "3" + case modeDisasm: + return "4" + case modeHex: + return "5" + case modeRaw: + return "6" + case modeStrings: + return "7" + case modeLibs: + return "8" + case modeSources: + return "9" + case modeRelocs: + return "0" + } + return " " +} + +// caret is the position under the cursor, expressed as a virtual address, a file +// offset, or both — a string in an unmapped section has only an offset (so it can +// still open in Raw), while a disasm/hex caret has an address (and usually a +// backing offset too). +type caret struct { + addr uint64 + hasAddr bool + off uint64 + hasOff bool +} + +// fromAddr fills in the file offset backing a virtual address, when it has one. +func (m *Model) caretFromAddr(addr uint64) caret { + c := caret{addr: addr, hasAddr: true} + if off, ok := m.fileOffsetForAddr(addr); ok { + c.off, c.hasOff = off, true + } + return c +} + +// caretPos returns the position under the cursor of the active view. ok is false +// for views with no address/offset concept (e.g. Info) or an empty selection. +func (m *Model) caretPos() (caret, bool) { + switch m.mode { + case modeInfo: + // Info has no cursor, so open at the binary's natural starting point: its + // entry point when it has one, else the lowest mapped virtual address. + if a := m.file.Entry(); a != 0 { + return m.caretFromAddr(a), true + } + if a, ok := m.lowestVirtAddr(); ok { + return m.caretFromAddr(a), true + } + case modeDisasm: + if len(m.dasm.Inst) > 0 && m.dasm.Cur >= 0 && m.dasm.Cur < len(m.dasm.Inst) { + return m.caretFromAddr(m.dasm.Inst[m.dasm.Cur].Addr), true + } + case modeHex: + if a, ok := m.byteViews.HexCaretAddr(); ok { + return m.caretFromAddr(a), true + } + case modeRaw: + off := m.byteViews.RawCaretOffset() + c := caret{off: off, hasOff: true} + if a, ok := m.addrForOffset(off); ok { + c.addr, c.hasAddr = a, true + } + return c, true + case modeSymbols: + if a, ok := m.symbols.CaretAddr(m.viewContext()); ok { + return m.caretFromAddr(a), true + } + case modeSections: + if a, ok := m.sections.CaretAddr(); ok { + return m.caretFromAddr(a), true + } + case modeStrings: + // A string always has a file offset; a virtual address only when it lives in + // a mapped section (or its offset resolves to one). + if s, ok := m.strs.Current(); ok { + c := caret{off: s.Offset, hasOff: true} + if s.HasAddr && s.Addr != 0 { + c.addr, c.hasAddr = s.Addr, true + } else if a, ok := m.addrForOffset(s.Offset); ok { + c.addr, c.hasAddr = a, true + } + return c, true + } + case modeRelocs: + if a, ok := m.relocs.CaretAddr(m.viewContext()); ok { + return m.caretFromAddr(a), true + } + } + return caret{}, false +} + +// openJumpModal builds the target list for the caret position and opens the +// modal, landing the selection on the first reachable target. It reports (without +// opening) when the active view has no position under the cursor, or when nothing +// else can show it. +func (m *Model) openJumpModal() { + c, ok := m.caretPos() + if !ok { + m.setStatus("no address or offset at the caret to open elsewhere", true) + return + } + m.jumpCaret = c + if !m.jump.Open(m.jumpHeader(c), m.buildJumpTargets(c)) { + m.setStatus("nothing else can open this position", true) + } +} + +// jumpHeader resolves what the caret address *is* into the plain strings the +// overlay renders. +func (m *Model) jumpHeader(c caret) jumptomodal.Header { + h := jumptomodal.Header{Loc: fmt.Sprintf("0x%x", c.addr)} + if !c.hasAddr { + h.Loc = fmt.Sprintf("file 0x%x", c.off) + } + h.Context = m.caretContextLine(c) + if c.hasAddr { + h.Pointer = m.caretPointer(c.addr) + } + return h +} + +// OpenCaretIn performs the jump for a target row. It satisfies jumpto.Host; the +// id is the destination view's mode. +func (m *Model) OpenCaretIn(id int) { + c := m.jumpCaret + switch mode(id) { + case modeRaw: + // Raw is addressed by file offset, so it works even for an offset-only caret. + m.openRawAt(c.off) + case modeDisasm: + m.jumpDisasmAtAddr(c.addr) + case modeHex: + m.jumpHexAtAddr(c.addr) + case modeSymbols: + m.jumpSymbolsAtAddr(c.addr) + case modeSections: + m.jumpSectionsAtAddr(c.addr) + case modeStrings: + if c.hasAddr { + m.jumpStringsAtAddr(c.addr) + } else { + m.jumpStringsAtOffset(c.off) + } + case modeRelocs: + m.jumpRelocsAtAddr(c.addr) + } +} + +// buildJumpTargets assembles the destination rows for the caret, skipping the +// view it is already in. Address-keyed targets need c.hasAddr; Raw needs only +// c.hasOff, so an offset-only string can still be opened there. Each row's preview +// and enabled flag come from the same resolution the jump itself will use. +func (m *Model) buildJumpTargets(c caret) []jumptomodal.Target { + addr := c.addr + var sym binfile.Symbol + var hasSym bool + var sec *binfile.Section + if c.hasAddr { + sym, hasSym = m.file.SymbolAt(addr) + sec = m.file.SectionAt(addr) + } + var out []jumptomodal.Target + add := func(md mode, label, preview string, enabled bool) { + if md == m.mode { + return + } + out = append(out, jumptomodal.Target{ + ID: int(md), Digit: modeDigit(md), Label: label, Preview: preview, Enabled: enabled, + }) + } + + canDis := c.hasAddr && m.canDisasmAt(addr) + disEnabled := canDis || (c.hasAddr && m.file.AddrDisassemblable(addr)) + disPrev := "not executable" + switch { + case canDis: + if s := m.symbolDisplayAt(addr); s != "" { + disPrev = s + } else if sec != nil { + disPrev = sec.Name + } else { + disPrev = "code" + } + case disEnabled: + disPrev = "decode bytes here" // a non-exec section, via disasm-all + case !c.hasAddr: + disPrev = "no virtual address" + } + add(modeDisasm, "Disasm", disPrev, disEnabled) + + hexPrev := "no virtual address" + if sec != nil { + hexPrev = fmt.Sprintf("0x%x %s", addr, sec.Name) + } else if c.hasAddr { + hexPrev = "unmapped" + } + add(modeHex, "Hex", hexPrev, c.hasAddr && addr != 0 && sec != nil) + + rawPrev := "no file bytes" + if c.hasOff { + rawPrev = fmt.Sprintf("offset 0x%x", c.off) + } + add(modeRaw, "Raw", rawPrev, c.hasOff) + + symPrev := "no symbol here" + if !c.hasAddr { + symPrev = "no virtual address" + } else if hasSym && sym.Name != "" { + symPrev = m.displaySymbolName(sym) + } + add(modeSymbols, "Symbols", symPrev, hasSym && sym.Name != "") + + secPrev := "no section" + if !c.hasAddr { + secPrev = "no virtual address" + } else if sec != nil { + secPrev = sec.Name + } + add(modeSections, "Sections", secPrev, sec != nil) + + // A string can be found by virtual address or — when the caret has only an + // offset (a Raw caret over an unmapped region) — by file offset, so the Strings + // target works from either. + strPrev := "no string here" + strOK := false + if s, ok := m.stringAtCaret(c); ok { + strPrev = strconvQuote(m.file.StringText(s), 40) + strOK = true + } + add(modeStrings, "Strings", strPrev, strOK) + + relPrev := "no relocations" + relOK := false + if !c.hasAddr { + relPrev = "no virtual address" + } else if m.file.HasRelocs() { + if prev, ok := m.relocPreviewAt(addr); ok { + relPrev, relOK = prev, true + } else { + relPrev = "none patch here" + } + } + add(modeRelocs, "Relocs", relPrev, relOK) + + return out +} + +// stringAtCaret finds the string covering the caret, by virtual address when it +// has one, else by file offset — so a Raw caret over an unmapped string still +// resolves. +func (m *Model) stringAtCaret(c caret) (binfile.StringEntry, bool) { + if c.hasAddr { + if s, ok := m.strs.StringAt(m.viewContext(), c.addr); ok { + return s, true + } + } + if c.hasOff { + return m.strs.StringAtOffset(m.viewContext(), c.off) + } + return binfile.StringEntry{}, false +} + +// strconvQuote renders s as a compact quoted preview truncated to max runes. +func strconvQuote(s string, max int) string { + if len(s) > max { + s = s[:max] + "…" + } + return "“" + s + "”" +} + +// relocPreviewAt describes the relocations patching exactly addr (forcing the +// lazy reloc build, which the user's action justifies): the type + bound symbol +// for a single one, else a count. ok is false when none patch addr. +func (m *Model) relocPreviewAt(addr uint64) (string, bool) { + var hit []string + for _, r := range m.file.RelocsInRange(addr, addr+1) { + if r.Offset != addr { + continue + } + if r.Sym != "" { + hit = append(hit, r.Type+" "+r.Sym) + } else { + hit = append(hit, r.Type) + } + } + switch len(hit) { + case 0: + return "", false + case 1: + return hit[0], true + default: + return fmt.Sprintf("%d relocations", len(hit)), true + } +} + +// caretContextLine describes what the caret *is*: the covering symbol (demangled, +// with any offset) and the section it lives in, for the modal header. Without a +// virtual address it falls back to the section that owns the file offset. +func (m *Model) caretContextLine(c caret) string { + var parts []string + if c.hasAddr { + if sym, ok := m.file.SymbolAt(c.addr); ok && sym.Name != "" { + if off := c.addr - sym.Addr; off > 0 { + parts = append(parts, fmt.Sprintf("%s+0x%x", m.displaySymbolName(sym), off)) + } else { + parts = append(parts, m.displaySymbolName(sym)) + } + } + if sec := m.file.SectionAt(c.addr); sec != nil { + parts = append(parts, sec.Name) + } + } else if c.hasOff { + if sec := m.sectionAtOffset(c.off); sec != nil { + parts = append(parts, sec.Name) + } + } + return strings.Join(parts, " · ") +} + +// caretPointerValue reads the pointer-sized little-endian word at addr, returning +// it when it is itself a mapped address (a GOT slot, a vtable entry, a relocated +// pointer). ok is false when there are no bytes there or the word isn't a mapped +// address. +func (m *Model) caretPointerValue(addr uint64) (uint64, bool) { + img := m.file.VAImage() + if img == nil { + return 0, false + } + pos, ok := img.PosForAddr(addr) + if !ok { + return 0, false + } + ps := m.file.PointerBytes() + b := img.Bytes(pos, pos+ps) + if len(b) < ps { + return 0, false + } + var v uint64 + for i := ps - 1; i >= 0; i-- { // little-endian word + v = v<<8 | uint64(b[i]) + } + if v == 0 || !m.file.IsMapped(v) { + return 0, false + } + return v, true +} + +// caretPointerAt reads the pointer-width little-endian word at the caret — by +// virtual address when it has one, else straight from the raw bytes at its file +// offset — returning it when it is itself a mapped address. This lets a Raw caret +// over an unmapped region (a header, padding) still search for the pointer it +// holds. +func (m *Model) caretPointerAt(c caret) (uint64, bool) { + if c.hasAddr { + if v, ok := m.caretPointerValue(c.addr); ok { + return v, true + } + } + if c.hasOff { + raw := m.file.Raw() + ps := m.file.PointerBytes() + if c.off+uint64(ps) <= uint64(len(raw)) { + var v uint64 + for i := ps - 1; i >= 0; i-- { + v = v<<8 | uint64(raw[c.off+uint64(i)]) + } + if v != 0 && m.file.IsMapped(v) { + return v, true + } + } + } + return 0, false +} + +// caretPointer describes where the pointer at addr points, for the jump modal's +// header — the most useful single fact about a data slot. Empty when there is no +// mapped pointer there. +func (m *Model) caretPointer(addr uint64) string { + v, ok := m.caretPointerValue(addr) + if !ok { + return "" + } + if ann := m.targetAnnotation(v); ann != "" { + return fmt.Sprintf("→ 0x%x %s", v, ann) + } + return fmt.Sprintf("→ 0x%x", v) +} diff --git a/internal/ui/jumpto_test.go b/internal/ui/jumpto_test.go new file mode 100644 index 0000000..d3135c5 --- /dev/null +++ b/internal/ui/jumpto_test.go @@ -0,0 +1,197 @@ +package ui + +import ( + "testing" + + jumptomodal "github.com/rabarbra/exex/internal/ui/modals/jumpto" +) + +// TestJumpModalFromDisasm: the cross-view "open caret in…" modal opens on the +// disasm cursor's address, offers the other address views (not Disasm itself) +// with resolved previews, and Enter navigates to the selected target. +func TestJumpModalFromDisasm(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press(">") + if !h.m().jump.Active() { + t.Fatalf("jump modal did not open from disasm; status=%q", h.m().status) + } + + byLabel := map[string]jumptomodal.Target{} + for _, tg := range h.m().jump.Targets() { + byLabel[tg.Label] = tg + if tg.ID == int(modeDisasm) { + t.Error("jump modal offered the current (Disasm) view as a target") + } + } + // Hex/Raw/Sections are reliably reachable for any mapped code caret. Symbols + // depends on a covering symbol, which a stripped binary (e.g. Ubuntu's system + // binaries) doesn't have — so require it only as a listed target, not enabled. + for _, want := range []string{"Hex", "Raw", "Sections"} { + tg, ok := byLabel[want] + if !ok { + t.Errorf("missing %s target", want) + continue + } + if !tg.Enabled || tg.Preview == "" { + t.Errorf("%s target should be enabled with a preview, got enabled=%v preview=%q", want, tg.Enabled, tg.Preview) + } + } + if _, ok := byLabel["Symbols"]; !ok { + t.Error("missing Symbols target") + } + + // The selection starts on the first enabled row; Enter navigates and closes. + h.press("enter") + if h.m().jump.Active() { + t.Error("modal still open after Enter") + } + if h.m().mode == modeDisasm { + t.Error("Enter did not navigate away from Disasm") + } +} + +// TestJumpModalDisabledRowSkipped: selection movement lands only on reachable +// targets, and Esc dismisses without navigating. +func TestJumpModalNavAndEsc(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press(">") + if !h.m().jump.Active() { + t.Skip("no caret address to open from") + } + // Every landing the cursor stops on must be an enabled target. + for i := 0; i < len(h.m().jump.Targets())+1; i++ { + if tg := h.m().jump.Targets()[h.m().jump.Sel()]; !tg.Enabled { + t.Fatalf("selection rested on a disabled target %q", tg.Label) + } + h.press("down") + } + h.press("esc") + if h.m().jump.Active() { + t.Error("Esc did not close the jump modal") + } + if h.m().mode != modeDisasm { + t.Errorf("Esc navigated away: mode=%v", h.m().mode) + } +} + +// TestJumpModalFromInfo: the Info view has no cursor, so the modal opens on the +// binary's natural starting point — the entry point (or the lowest mapped address +// when there is none) — rather than reporting "no address". +func TestJumpModalFromInfo(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeInfo, "1") + h.press("space") + if !h.m().jump.Active() { + t.Fatalf("modal did not open from Info; status=%q", h.m().status) + } + if !h.m().jumpCaret.hasAddr { + t.Error("Info caret has no virtual address") + } + if entry := h.m().file.Entry(); entry != 0 && h.m().jumpCaret.addr != entry { + t.Errorf("Info caret = 0x%x, want entry 0x%x", h.m().jumpCaret.addr, entry) + } + // Info is not an address view, so it isn't offered as a target. + for _, tg := range h.m().jump.Targets() { + if tg.ID == int(modeInfo) { + t.Error("Info offered as a jump target") + } + } +} + +// TestJumpModalRawStringByOffset: a Raw caret parked on a string's file offset — +// in an unmapped region with no virtual address — must still offer the Strings +// target, found by offset (regression: it used to report "nothing can open this"). +func TestJumpModalRawStringByOffset(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeStrings, "7") + if len(h.m().strs.Filtered) == 0 { + t.Skip("no strings") + } + s, _ := h.m().strs.Current() + h.m().openRawAt(s.Offset) + h.m().setMode(modeRaw) + h.press("space") + if !h.m().jump.Active() { + t.Fatalf("modal did not open from Raw; status=%q", h.m().status) + } + strEnabled := false + for _, tg := range h.m().jump.Targets() { + if tg.ID == int(modeStrings) && tg.Enabled { + strEnabled = true + } + } + if !strEnabled { + t.Error("Strings target not enabled at a string's file offset in the Raw view") + } + // Opening it lands in the Strings view on that string. + h.press("7") + if h.m().jump.Active() || h.m().mode != modeStrings { + t.Errorf("digit 7 did not open Strings: active=%v mode=%v", h.m().jump.Active(), h.m().mode) + } +} + +// TestJumpModalSpaceAndDigit: space opens the modal, and a target's view digit +// jumps straight to it. +func TestJumpModalSpaceAndDigit(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeDisasm, "4") + h.press("space") + if !h.m().jump.Active() { + t.Fatalf("space did not open the jump modal; status=%q", h.m().status) + } + // Every listed target carries its view digit; Hex (5) is always reachable from + // a mapped code address. + hasHex := false + for _, tg := range h.m().jump.Targets() { + if tg.ID == int(modeHex) && tg.Enabled && tg.Digit == "5" { + hasHex = true + } + } + if !hasHex { + t.Fatal("expected an enabled Hex (5) target") + } + h.press("5") + if h.m().jump.Active() || h.m().mode != modeHex { + t.Errorf("digit 5 did not open Hex: active=%v mode=%v", h.m().jump.Active(), h.m().mode) + } +} + +// TestJumpModalFromStrings: a string always has a file offset even when it has no +// virtual address, so the modal must open from the Strings view with Raw enabled +// (offset-addressed) — the previous behaviour reported "no address" and refused. +func TestJumpModalFromStrings(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeStrings, "7") + if len(h.m().strs.Filtered) == 0 { + t.Skip("no strings") + } + h.press("space") + if !h.m().jump.Active() { + t.Fatalf("space did not open the modal from Strings; status=%q", h.m().status) + } + if !h.m().jumpCaret.hasOff { + t.Error("string caret has no file offset") + } + rawEnabled, offeredStrings := false, false + for _, tg := range h.m().jump.Targets() { + if tg.ID == int(modeRaw) && tg.Enabled { + rawEnabled = true + } + if tg.ID == int(modeStrings) { + offeredStrings = true // must not offer the current view + } + } + if !rawEnabled { + t.Error("Raw target not enabled from a string caret (offset is always available)") + } + if offeredStrings { + t.Error("Strings offered as a target while already in the Strings view") + } + // Jumping to Raw lands in the Raw view. + h.press("6") + if h.m().jump.Active() || h.m().mode != modeRaw { + t.Errorf("digit 6 did not open Raw: active=%v mode=%v", h.m().jump.Active(), h.m().mode) + } +} diff --git a/internal/ui/key_dispatch.go b/internal/ui/key_dispatch.go index 1a1b2f3..01ddf67 100644 --- a/internal/ui/key_dispatch.go +++ b/internal/ui/key_dispatch.go @@ -24,46 +24,19 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // after the user has moved on. m.pendingWheel = 0 - // While the help overlay is up, scroll keys page through it (it can be taller - // than the terminal); any other key dismisses it. - if m.headerActive { - switch key { - case "up", "k": - m.headerScroll-- - case "down", "j": - m.headerScroll++ - case "pgup": - m.headerScroll -= headerPageStep - case "pgdown": - m.headerScroll += headerPageStep - case "home", "g": - m.headerScroll = 0 - case "end", "G": - m.headerScroll = 1 << 20 - default: - m.headerActive = false - } + // While a scrollable text overlay is up, scroll keys page through it (it can + // be taller than the terminal); any other key dismisses it. These sit above + // the running-scan Esc handling below, so Esc dismisses the overlay in front + // of the user rather than cancelling a scan they can't see. + switch m.activeModal() { + case modalHeader: + m.header.Update(key) return m, nil - } - if m.helpActive { - switch key { - case "up", "k": - m.helpScroll-- - case "down", "j": - m.helpScroll++ - case "pgup": - m.helpScroll -= helpPageStep - case "pgdown": - m.helpScroll += helpPageStep - case "home", "g": - m.helpScroll = 0 - case "end", "G": - m.helpScroll = 1 << 20 // clamped to the bottom in renderHelpModal - default: - m.helpActive = false - } + case modalHelp: + m.help.Update(key) return m, nil } + if m.searchRunning && key == "esc" { m.cancelSearch("search cancelled") return m, nil @@ -77,7 +50,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } if m.syscallFullRunning && key == "esc" { - m.cancelSyscallFullScan() + m.CancelFullScan() m.setStatus("syscall scan cancelled", false) return m, nil } @@ -85,25 +58,30 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cancelCPUFeat() return m, nil } - if m.cpufeatActive { - return m.updateCPUFeatModal(key) - } - - if m.xrefActive { - return m.updateXrefModal(msg, key) - } - if m.syscallActive { - return m.updateSyscallModal(msg, key) - } - if m.settingsActive { - return m.updateSettings(key) - } - if m.gotoActive { - return m.updateGotoInput(msg, key) - } - if m.searchActive { + // The open modal owns the keyboard. modalHeader/modalHelp returned above. + switch m.activeModal() { + case modalCPUFeat: + return m, m.cpufeat.Update(m.modalContext(), m, key) + case modalXref: + return m, m.xref.Update(m, msg, key) + case modalSyscall: + return m, m.syscalls.Update(m, msg, key) + case modalJump: + return m, m.jump.Update(m, key) + case modalFind: + return m, m.find.Update(m, key) + case modalFindQuery: + return m, m.findQueryModal.Update(m, msg, key) + case modalFindResults: + return m, m.findResults.Update(m, msg, key) + case modalSettings: + return m, m.settings.Update(m, key) + case modalGoto: + return m, m.palette.Update(m, msg, key) + case modalSearch: return m.updateSearchInput(msg, key) } + if model, cmd, ok := m.handleDisasmPaneKey(key); ok { return model, cmd } @@ -114,8 +92,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // '?' toggles the keybinding cheat-sheet (after modal/filter capture, so it // still types into inputs). if key == "?" { - m.helpActive = true - m.helpScroll = 0 + m.help.Open() return m, nil } // `tab` doubles as the mode-toggle (the `t` key) in every non-disasm view — @@ -302,17 +279,17 @@ type cursorState struct { func (m *Model) activeCursorState() cursorState { return cursorState{ mode: m.mode, - sectionsCur: m.sectionsCur, - symbolsCur: m.symbolsCur, - disasmCur: m.disasmCur, - hexCur: m.hexCur, - rawCur: m.rawCur, - stringsCur: m.stringsCur, - sourcesCur: m.sourcesCur, - libsCur: m.libsCur, + sectionsCur: m.sections.Cur, + symbolsCur: m.symbols.Cur, + disasmCur: m.dasm.Cur, + hexCur: m.byteViews.HexCur, + rawCur: m.byteViews.RawCur, + stringsCur: m.strs.Cur, + sourcesCur: m.sources.Cur, + libsCur: m.libs.Cur, memberSel: m.memberSel, - srcFile: m.srcFile, - srcCur: m.srcCur, + srcFile: m.dasm.SrcFile, + srcCur: m.dasm.SrcCur, } } @@ -346,103 +323,20 @@ func (m *Model) handleDisasmPaneKey(key string) (tea.Model, tea.Cmd, bool) { return m, nil, false } -func (m *Model) updateGotoInput(msg tea.KeyMsg, key string) (tea.Model, tea.Cmd) { - switch key { - case "esc": - m.closeGoto() - return m, nil - case "up": - if m.gotoSel > 0 { - m.gotoSel-- - } - return m, nil - case "down": - if m.gotoSel < len(m.gotoResults)-1 { - m.gotoSel++ - } - return m, nil - case "enter": - m.activateGoto() - m.closeGoto() - return m, nil - case "tab": - m.gotoScope = (m.gotoScope + 1) % gsScopeCount - m.recomputeGoto() - return m, nil - case "shift+tab": - m.gotoScope = (m.gotoScope + gsScopeCount - 1) % gsScopeCount - m.recomputeGoto() - return m, nil - case "ctrl+p": - // Toggle physical-address interpretation (only meaningful when LMA differs). - if m.file.HasPhysAddrs() { - m.gotoAddrPhys = !m.gotoAddrPhys - m.recomputeGoto() - } - return m, nil - } - var cmd tea.Cmd - m.gotoInput, cmd = m.gotoInput.Update(msg) - m.recomputeGoto() - return m, cmd -} - +// updateSearchInput applies the user's search-key aliases, then hands the key to +// the prompt overlay. func (m *Model) updateSearchInput(msg tea.KeyMsg, key string) (tea.Model, tea.Cmd) { if c, ok := m.searchKeyAlias[key]; ok { key = c } - switch key { - case "esc": - m.searchActive = false - m.searchInput.Blur() - return m, nil - case "ctrl+t": - m.cycleSearchMode() - return m, nil - case "ctrl+r": - m.searchForward = !m.searchForward - return m, nil - case "ctrl+o": - m.searchFromCursor = !m.searchFromCursor - return m, nil - case "enter": - before := m.activeCursorState() - m.searchQuery = strings.TrimSpace(m.searchInput.Value()) - m.searchActive = false - m.searchInput.Blur() - cmd := m.runSearchFromPrompt() - if before != m.activeCursorState() { - m.viewportDetached = false - m.pinCurrentByteSectionStart() - } - return m, cmd - } - var cmd tea.Cmd - m.searchInput, cmd = m.searchInput.Update(msg) - return m, cmd + return m, m.search.Update(m, msg, key) } func (m *Model) captureActiveFilter(key string, msg tea.KeyMsg) (tea.Cmd, bool) { // A focused filter input captures typing keys (esc/enter blur it); navigation // keys fall through so they still drive the list. Shared across the three // filterable views via filterCapture. - switch m.mode { - case modeSymbols: - return filterCapture(&m.symbolsFilter, key, msg, m.recomputeSymbols) - case modeSections: - return filterCapture(&m.sectionsFilter, key, msg, m.recomputeSections) - case modeStrings: - return filterCapture(&m.stringsFilter, key, msg, m.recomputeStrings) - case modeLibs: - return filterCapture(&m.libsFilter, key, msg, m.buildLibRows) - case modeRelocs: - return filterCapture(&m.relocFilter, key, msg, m.recomputeRelocs) - case modeSources: - if m.srcFile == "" { - return filterCapture(&m.sourcesFilter, key, msg, m.recomputeSourceFiles) - } - } - return nil, false + return m.current().captureFilter(key, msg) } func (m *Model) handleGlobalAction(key string) (tea.Model, tea.Cmd, bool) { @@ -470,9 +364,7 @@ func (m *Model) handleGlobalAction(key string) (tea.Model, tea.Cmd, bool) { case actionViewRelocs: return m, m.switchMode(modeRelocs), true case actionGoto: - m.gotoActive = true - m.gotoInput.Focus() - m.recomputeGoto() + m.palette.Open(m) return m, nil, true case actionToggleSource: m.toggleSourcePane() @@ -488,8 +380,16 @@ func (m *Model) handleGlobalAction(key string) (tea.Model, tea.Cmd, bool) { case actionCPUFeatures: return m, m.startCPUFeatScan(), true case actionHeader: - m.headerActive = true - m.headerScroll = 0 + m.header.Open() + return m, nil, true + case actionJumpCaret: + m.openJumpModal() + return m, nil, true + case actionFindCaret: + m.openFindModal() + return m, nil, true + case actionFindQuery: + m.findQueryModal.Open() return m, nil, true } return m, nil, false @@ -503,8 +403,8 @@ func (m *Model) toggleSourcePane() { m.setStatus("no debug info — source pane unavailable", true) return } - m.showSource = !m.showSource - m.rightScroll = 0 + m.dasm.ShowSource = !m.dasm.ShowSource + m.dasm.RightScroll = 0 } func (m *Model) switchSourcePane() { @@ -515,14 +415,14 @@ func (m *Model) switchSourcePane() { m.setStatus("no debug info — source pane unavailable", true) return } - if m.sourceFirst { - m.sourceFirst = false - m.rightScroll = 0 + if m.dasm.SourceFirst { + m.dasm.SourceFirst = false + m.dasm.RightScroll = 0 return } if m.ensureSourcePaneAvailable() { - m.sourceFirst = true - m.rightScroll = 0 + m.dasm.SourceFirst = true + m.dasm.RightScroll = 0 return } m.setStatus("no source mapping for current instruction", true) @@ -538,31 +438,24 @@ func (m *Model) ensureSourcePaneAvailable() bool { if m.hasOpenSourceFile() { return true } - m.ensureSources() - if len(m.sourcesFiltered) == 0 { + ctx := m.viewContext() + m.sources.Ensure(ctx) + if len(m.sources.Filtered) == 0 { return false } - start := m.sourcesCur - if start < 0 || start >= len(m.sourcesFiltered) { - start = 0 + selected := "" + if f, ok := m.sources.CurrentFile(); ok { + selected = f + if m.file.SourceLines(f) != nil && m.openSourceFile(f, 1) { + return true + } } - for offset := 0; offset < len(m.sourcesFiltered); offset++ { - idx := (start + offset) % len(m.sourcesFiltered) - file := m.sourcesFiles[m.sourcesFiltered[idx]] - src := m.file.SourceLines(file) - if src == nil { + for _, idx := range m.sources.Filtered { + file := m.sources.Files[idx] + if file == selected || m.file.SourceLines(file) == nil { continue } - m.sourcesCur = idx - m.srcFile = file - m.srcCodeLines = m.mappedSourceLines(file) - m.srcCur = 1 - if len(src) == 0 { - m.srcCur = 0 - } - m.srcTop = 0 - m.syncSourceAsm() - return true + return m.openSourceFile(file, 1) } return false } @@ -591,27 +484,5 @@ func (m *Model) normalizeNavKey(key string) string { } func (m *Model) dispatchViewKey(msg tea.KeyMsg, key string) (tea.Model, tea.Cmd) { - switch m.mode { - case modeSections: - return m.updateSections(key) - case modeSymbols: - return m.updateSymbols(key) - case modeDisasm: - return m.updateDisasm(key) - case modeHex: - return m.updateHex(key) - case modeRaw: - return m.updateRaw(key) - case modeStrings: - return m.updateStrings(key) - case modeSources: - return m.updateSources(key) - case modeLibs: - return m.updateLibs(key) - case modeRelocs: - return m.updateRelocs(key) - case modeInfo: - return m.updateInfo(msg, key) - } - return m, nil + return m.current().handleKey(msg, key) } diff --git a/internal/ui/key_dispatch_test.go b/internal/ui/key_dispatch_test.go index aaaa598..7300de5 100644 --- a/internal/ui/key_dispatch_test.go +++ b/internal/ui/key_dispatch_test.go @@ -11,20 +11,20 @@ import ( // the last frame), and the accumulated moves are applied together on the tick. func TestKeyCoalescing(t *testing.T) { m := &Model{ - theme: DefaultTheme(), - file: &binfile.File{}, - mode: modeStrings, - layoutState: layoutState{width: 80, height: 24}, - stringsState: stringsState{stringsList: make([]binfile.StringEntry, 5000)}, - } - m.stringsFilter = newPromptInput("", "/ ") - m.recomputeStrings() + theme: DefaultTheme(), + file: &binfile.File{}, + mode: modeStrings, + layoutState: layoutState{width: 80, height: 24}, + } + m.strs.List = make([]binfile.StringEntry, 5000) + m.strs.Filter = newPromptInput("", "/ ") + m.strs.Recompute(m.viewContext()) m.enqueueNavKey("down") if !m.keyTicking { t.Fatal("first nav key should start the coalescing tick") } - first := m.stringsCur + first := m.strs.Cur if first == 0 { t.Fatal("first press should move the cursor immediately") } @@ -36,15 +36,15 @@ func TestKeyCoalescing(t *testing.T) { t.Fatal("a coalesced repeat must leave the frame clean so View() is skipped") } } - if m.stringsCur != first { - t.Fatalf("repeats moved the cursor mid-flood (%d → %d); they should only accumulate", first, m.stringsCur) + if m.strs.Cur != first { + t.Fatalf("repeats moved the cursor mid-flood (%d → %d); they should only accumulate", first, m.strs.Cur) } if m.pendingKeyN == 0 { t.Fatal("repeats should have accumulated a pending count") } m.handleKeyTick() - if m.stringsCur == first { + if m.strs.Cur == first { t.Fatal("tick should apply the accumulated moves") } if m.pendingKeyN != 0 { diff --git a/internal/ui/keybindings_test.go b/internal/ui/keybindings_test.go index 2bc1ee5..b7114c3 100644 --- a/internal/ui/keybindings_test.go +++ b/internal/ui/keybindings_test.go @@ -17,6 +17,10 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/config" + "github.com/rabarbra/exex/internal/ui/view" + "github.com/rabarbra/exex/internal/ui/views/sources" + "github.com/rabarbra/exex/internal/ui/views/strs" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) // kp converts a chord description to the KeyPressMsg a terminal would send. @@ -106,11 +110,7 @@ func newKeyHarness(t *testing.T, path string) *keyHarness { func (h *keyHarness) pump(msg tea.Msg) { h.t.Helper() h.model, _ = h.model.Update(msg) - if mm, ok := h.model.(*Model); ok && mm.disasmDecoding { - addr := mm.disasmPendingAddr - win, insts := mm.decodeDisasmAt(addr, mm.disasmLeadBytes()) - h.model, _ = h.model.Update(disasmReadyMsg{addr: addr, posLo: win.Start, posHi: win.End, insts: insts}) - } + h.model = settleDisasmDecode(h.model) // Render every frame, exactly as the program loop does, so list/tree rows are // always rebuilt before the next key (and any render panic is caught here). if strings.TrimSpace(h.model.View().Content) == "" { @@ -153,28 +153,28 @@ func TestKeysGlobal(t *testing.T) { // g opens goto; esc closes. h.press("g") - if !h.m().gotoActive { + if !h.m().palette.Active() { t.Fatal("g did not open goto modal") } h.press("esc") - if h.m().gotoActive { + if h.m().palette.Active() { t.Fatal("esc did not close goto modal") } // , opens settings; esc closes. h.press(",") - if !h.m().settingsActive { + if !h.m().settings.Active() { t.Fatal(", did not open settings") } h.press("esc") // ? opens help; the next key dismisses it. h.press("?") - if !h.m().helpActive { + if !h.m().help.Active() { t.Fatal("? did not open help") } h.press("x") - if h.m().helpActive { + if h.m().help.Active() { t.Fatal("help overlay did not dismiss on next key") } @@ -194,37 +194,37 @@ func TestKeysSections(t *testing.T) { h.goView(modeSections, "2") // s cycles the sort field; r reverses. - s0 := h.m().sectionsSort + s0 := h.m().sections.Sort h.press("s") - if h.m().sectionsSort == s0 { + if h.m().sections.Sort == s0 { t.Fatal("s did not change the sort field") } - d0 := h.m().sectionsSortDesc + d0 := h.m().sections.SortDesc h.press("r") - if h.m().sectionsSortDesc == d0 { + if h.m().sections.SortDesc == d0 { t.Fatal("r did not reverse the sort") } // t cycles sections -> segments -> header -> sections (when the binary has // segments). Cycle all the way back to sections for the jump tests below. - if len(h.m().segments) > 0 { - seg0 := h.m().showSegments + if len(h.m().sections.Segments) > 0 { + seg0 := h.m().sections.ShowSegments h.press("t") - if h.m().showSegments == seg0 { + if h.m().sections.ShowSegments == seg0 { t.Fatal("t did not toggle sections/segments") } h.press("t") // -> back to sections (2-state cycle now) - if h.m().showSegments { + if h.m().sections.ShowSegments { t.Fatal("second t did not return to the section table") } } // The raw header moved from a Sections sub-mode to the ⇧H overlay. h.press("H") - if !h.m().headerActive { + if !h.m().header.Active() { t.Fatal("H did not open the raw-header overlay") } h.press("esc") // close it so the rest of the section-key checks run on the table - if h.m().headerActive { + if h.m().header.Active() { t.Fatal("esc did not close the raw-header overlay") } @@ -255,10 +255,10 @@ func TestKeysSections(t *testing.T) { func selectExecSection(t *testing.T, h *keyHarness) { t.Helper() m := h.m() - for i, idx := range m.sectionsFiltered { - s := m.sections[idx] + for i, idx := range m.sections.Filtered { + s := m.sections.Sections[idx] if binfile.IsExecSection(&s) && s.Addr != 0 { - m.sectionsCur = i + m.sections.Cur = i return } } @@ -275,41 +275,41 @@ func TestKeysSymbols(t *testing.T) { } // s cycles sort, r reverses. - s0 := h.m().symbolsSort + s0 := h.m().symbols.Sort h.press("s") - if h.m().symbolsSort == s0 { + if h.m().symbols.Sort == s0 { t.Fatal("s did not change symbol sort") } - d0 := h.m().symbolsSortDesc + d0 := h.m().symbols.SortDesc h.press("r") - if h.m().symbolsSortDesc == d0 { + if h.m().symbols.SortDesc == d0 { t.Fatal("r did not reverse symbol sort") } // ctrl+t / ctrl+s / ctrl+b drive the column filters. h.press("ctrl+t") - if !h.m().symbolsKindOn { + if !h.m().symbols.KindOn { t.Fatal("ctrl+t did not enable the type filter") } - sc0 := h.m().symbolsScope + sc0 := h.m().symbols.Scope h.press("ctrl+s") - if h.m().symbolsScope == sc0 { + if h.m().symbols.Scope == sc0 { t.Fatal("ctrl+s did not advance the scope filter") } h.press("ctrl+b") - if !h.m().symbolsBindOn { + if !h.m().symbols.BindOn { t.Fatal("ctrl+b did not enable the bind filter") } // esc clears every filter. h.press("esc") - if h.m().symbolsKindOn || h.m().symbolsBindOn || h.m().symbolsScope != scopeAll { + if h.m().symbols.KindOn || h.m().symbols.BindOn || h.m().symbols.Scope != symbols.ScopeAll { t.Fatal("esc did not clear symbol filters") } // t toggles tree/flat. - tr0 := h.m().symbolsTree + tr0 := h.m().symbols.Tree h.press("t") - if h.m().symbolsTree == tr0 { + if h.m().symbols.Tree == tr0 { t.Fatal("t did not toggle the symbol tree") } h.press("t") // back to flat @@ -341,13 +341,13 @@ func TestKeysSymbols(t *testing.T) { func selectFuncSymbol(t *testing.T, h *keyHarness) { t.Helper() m := h.m() - for i, r := range m.symbolsRows { - if r.node.leaf < 0 { + for i, r := range m.symbols.Rows { + if r.Node.Leaf < 0 { continue } - s := m.file.Symbols[r.node.leaf] + s := m.file.Symbols[r.Node.Leaf] if s.Kind == binfile.SymFunc && m.canDisasmAt(s.Addr) { - m.symbolsCur = i + m.symbols.Cur = i return } } @@ -359,24 +359,24 @@ func selectFuncSymbol(t *testing.T, h *keyHarness) { func TestKeysStrings(t *testing.T) { h := newKeyHarness(t, systemBinary(t)) h.goView(modeStrings, "7") - if len(h.m().stringsList) == 0 { + if len(h.m().strs.List) == 0 { t.Skip("binary has no printable strings") } // ctrl+s cycles the section filter (when section info is present). - if len(h.m().stringsSections) > 0 { + if len(h.m().strs.Sections) > 0 { h.press("ctrl+s") - if !h.m().stringsSecOn { + if !h.m().strs.SecOn { t.Fatal("ctrl+s did not enable the section filter") } h.press("esc") - if h.m().stringsSecOn { + if h.m().strs.SecOn { t.Fatal("esc did not clear the strings section filter") } } // Copy address/offset and the string text. - h.m().stringsCur = 0 + h.m().strs.Cur = 0 h.m().lastCopy = "" h.press("A") if !strings.HasPrefix(h.m().lastCopy, "0x") { @@ -390,10 +390,10 @@ func TestKeysStrings(t *testing.T) { // d/h/m jump to a mapped string when one exists; m always reaches raw. if i, ok := mappedStringRow(h.m()); ok { - h.m().stringsCur = i + h.m().strs.Cur = i h.goView(modeHex, "h") h.goView(modeStrings, "7") - h.m().stringsCur = i + h.m().strs.Cur = i // A mapped string usually lives in .rodata, not code; "d" only reaches the // disasm view when the string sits in an executable section (rare, and // platform-dependent for the system binary used here). Tolerate a refused @@ -404,13 +404,13 @@ func TestKeysStrings(t *testing.T) { } } h.goView(modeStrings, "7") - h.m().stringsCur = 0 + h.m().strs.Cur = 0 h.goView(modeRaw, "m") } func mappedStringRow(m *Model) (int, bool) { - for i, fi := range m.stringsFiltered { - if m.stringsList[fi].HasAddr { + for i, fi := range m.strs.Filtered { + if m.strs.List[fi].HasAddr { return i, true } } @@ -427,16 +427,16 @@ func TestKeysLibs(t *testing.T) { } // t toggles tree/flat. - tr0 := h.m().libsTree + tr0 := h.m().libs.Tree h.press("t") - if h.m().libsTree == tr0 { + if h.m().libs.Tree == tr0 { t.Fatal("t did not toggle the libs tree") } // ctrl+p cycles the availability filter. - av0 := h.m().libsAvail + av0 := h.m().libs.Avail h.press("ctrl+p") - if h.m().libsAvail == av0 { + if h.m().libs.Avail == av0 { t.Fatal("ctrl+p did not change the availability filter") } h.press("ctrl+p") @@ -454,10 +454,10 @@ func TestKeysLibs(t *testing.T) { func selectLibLeaf(t *testing.T, h *keyHarness) { t.Helper() m := h.m() - m.buildLibRows() - for i, r := range m.libsRows { - if r.node.leaf >= 0 { - m.libsCur = i + m.libs.BuildRows(m.viewContext()) + for i, r := range m.libs.Rows { + if r.Node.Leaf >= 0 { + m.libs.Cur = i return } } @@ -469,16 +469,16 @@ func selectLibLeaf(t *testing.T, h *keyHarness) { func TestKeysDisasm(t *testing.T) { h := newKeyHarness(t, systemBinary(t)) h.goView(modeDisasm, "4") - if h.m().dis == nil || len(h.m().disasmInst) == 0 { + if h.m().dis == nil || len(h.m().dasm.Inst) == 0 { t.Skip("no disassembly for this architecture/binary") } // ] / [ jump to the next / previous symbol (cursor moves). - c0 := h.m().disasmCur + c0 := h.m().dasm.Cur h.press("]") - moved := h.m().disasmCur != c0 + moved := h.m().dasm.Cur != c0 h.press("[") - if !moved && h.m().disasmCur == c0 { + if !moved && h.m().dasm.Cur == c0 { t.Log("symbol jump did not move (few symbols) — tolerated") } @@ -491,9 +491,9 @@ func TestKeysDisasm(t *testing.T) { // x kicks off an xref scan (sets xrefRunning or opens the modal via its cmd). h.press("x") // e toggles argument abbreviation globally. - ab0 := h.m().symbolsAbbrev + ab0 := h.m().symbols.Abbrev h.press("e") - if h.m().symbolsAbbrev == ab0 { + if h.m().symbols.Abbrev == ab0 { t.Fatal("e did not toggle argument abbreviation") } @@ -512,7 +512,7 @@ func TestKeysDisasm(t *testing.T) { // / opens the search modal. h.press("/") - if !h.m().searchActive { + if !h.m().search.Active() { t.Fatal("/ did not open the disasm search modal") } h.press("esc") @@ -530,26 +530,26 @@ func TestKeysHexRaw(t *testing.T) { h.goView(modeHex, "5") // t toggles the pointer/ascii column; i toggles the inspector. - pw0 := h.m().hexNumeric + pw0 := h.m().byteViews.Numeric h.press("t") - if h.m().hexNumeric == pw0 { + if h.m().byteViews.Numeric == pw0 { t.Fatal("t did not toggle the hex pointer column") } - in0 := h.m().hexInspect + in0 := h.m().byteViews.Inspect h.press("i") - if h.m().hexInspect == in0 { + if h.m().byteViews.Inspect == in0 { t.Fatal("i did not toggle the data inspector") } // Arrow keys (and j/k by row) move the byte cursor; h/l are reserved for // view-jumps per the doc, not horizontal movement. - cur0 := h.m().hexCur + cur0 := h.m().byteViews.HexCur h.press("right") - if h.m().hexCur == cur0 { + if h.m().byteViews.HexCur == cur0 { t.Fatal("right did not move the byte cursor") } h.press("left") - if h.m().hexCur != cur0 { + if h.m().byteViews.HexCur != cur0 { t.Fatal("left did not move the byte cursor back") } @@ -574,9 +574,9 @@ func TestKeysHexRaw(t *testing.T) { // Raw view: d jumps back to disasm for an allocated offset; t/i/copy work. h.goView(modeRaw, "6") - pw0 = h.m().hexNumeric + pw0 = h.m().byteViews.Numeric h.press("t") - if h.m().hexNumeric == pw0 { + if h.m().byteViews.Numeric == pw0 { t.Fatal("raw: t did not toggle the pointer column") } h.m().lastCopy = "" @@ -587,7 +587,7 @@ func TestKeysHexRaw(t *testing.T) { // Raw: h jumps to hex for an allocated offset. if off, ok := allocatedRawOffset(h.m()); ok { - h.m().rawCur = off + h.m().byteViews.RawCur = off h.goView(modeHex, "h") h.goView(modeRaw, "6") } @@ -595,7 +595,7 @@ func TestKeysHexRaw(t *testing.T) { // / opens the byte search modal. h.goView(modeHex, "5") h.press("/") - if !h.m().searchActive { + if !h.m().search.Active() { t.Fatal("/ did not open the hex search modal") } h.press("esc") @@ -613,12 +613,12 @@ func allocatedRawOffset(m *Model) (int, bool) { func selectExecHexByte(h *keyHarness) bool { m := h.m() - m.ensureHex() - for _, r := range m.hexImg.Regions { + m.byteViews.EnsureHex(m.viewContextPtr()) + for _, r := range m.byteViews.HexImg.Regions { addr := r.Addr if _, ok := m.file.ExecImage().PosForAddr(addr); ok { - if pos, ok := m.hexImg.PosForAddr(addr); ok { - m.hexCur = pos + if pos, ok := m.byteViews.HexImg.PosForAddr(addr); ok { + m.byteViews.HexCur = pos return true } } @@ -642,40 +642,40 @@ func TestKeysTreeNav(t *testing.T) { h.pump(tea.WindowSizeMsg{Width: 120, Height: 30}) h.goView(modeSymbols, "3") h.press("t") // switch to the namespace tree (builds the tree rows) - if !h.m().symbolsTree { + if !h.m().symbols.Tree { t.Fatal("t did not enable the symbol tree") } // + expands every group → the fully-expanded row count. h.press("+") - expanded := len(h.m().symbolsRows) + expanded := len(h.m().symbols.Rows) if expanded == 0 { t.Fatal("no tree rows after expand-all") } // - collapses every group → fewer rows. h.press("-") - if len(h.m().symbolsRows) >= expanded { - t.Fatalf("- did not collapse (%d >= %d)", len(h.m().symbolsRows), expanded) + if len(h.m().symbols.Rows) >= expanded { + t.Fatalf("- did not collapse (%d >= %d)", len(h.m().symbols.Rows), expanded) } // + expands back to the full set. h.press("+") - if len(h.m().symbolsRows) != expanded { - t.Fatalf("+ did not expand all (%d != %d)", len(h.m().symbolsRows), expanded) + if len(h.m().symbols.Rows) != expanded { + t.Fatalf("+ did not expand all (%d != %d)", len(h.m().symbols.Rows), expanded) } // Enter on the root recursively collapses the subtree below it. - h.m().symbolsCur = 0 + h.m().symbols.Cur = 0 h.press("enter") - if len(h.m().symbolsRows) >= expanded { + if len(h.m().symbols.Rows) >= expanded { t.Fatal("enter did not collapse the subtree below the root") } // right expands the current group one level. h.press("right") - if h.m().isSymbolCollapsed(h.m().symbolsRows[0].node.path) { + if h.m().symbols.IsCollapsed(h.m().symbols.Rows[0].Node.Path) { t.Fatal("right did not expand the group under the cursor") } // left collapses it again. h.press("left") - if !h.m().isSymbolCollapsed(h.m().symbolsRows[0].node.path) { + if !h.m().symbols.IsCollapsed(h.m().symbols.Rows[0].Node.Path) { t.Fatal("left did not collapse the group under the cursor") } } @@ -686,22 +686,22 @@ func TestKeysSources(t *testing.T) { bin := buildDebugSample(t) // skips when no C compiler h := newKeyHarness(t, bin) h.goView(modeSources, "9") - if !h.m().file.HasDWARF() || len(h.m().sourcesFiles) == 0 { + if !h.m().file.HasDWARF() || len(h.m().sources.Files) == 0 { t.Skip("sample has no usable DWARF source list") } // t toggles tree/flat. - tr0 := h.m().sourcesTree + tr0 := h.m().sources.Tree h.press("t") - if h.m().sourcesTree == tr0 { + if h.m().sources.Tree == tr0 { t.Fatal("t did not toggle the sources tree") } h.press("t") // ctrl+p cycles the availability filter. - av0 := h.m().sourcesAvail + av0 := h.m().sources.Avail h.press("ctrl+p") - if h.m().sourcesAvail == av0 { + if h.m().sources.Avail == av0 { t.Fatal("ctrl+p did not change the sources availability filter") } h.press("ctrl+p") @@ -718,17 +718,17 @@ func TestKeysSources(t *testing.T) { // Enter opens the file in the disasm source-first view. selectSourceLeaf(t, h) h.press("enter") - if h.m().mode != modeDisasm || !h.m().sourceFirst { - t.Fatalf("enter did not open source-first disasm (mode=%v sourceFirst=%v)", h.m().mode, h.m().sourceFirst) + if h.m().mode != modeDisasm || !h.m().dasm.SourceFirst { + t.Fatalf("enter did not open source-first disasm (mode=%v sourceFirst=%v)", h.m().mode, h.m().dasm.SourceFirst) } } func selectSourceLeaf(t *testing.T, h *keyHarness) { t.Helper() m := h.m() - for i, r := range m.sourcesRows { - if r.node.leaf >= 0 { - m.sourcesCur = i + for i, r := range m.sources.Rows { + if r.Node.Leaf >= 0 { + m.sources.Cur = i return } } @@ -756,7 +756,7 @@ func TestKeysCopyLine(t *testing.T) { check("disasm", "4", modeDisasm) check("hex", "5", modeHex) check("raw", "6", modeRaw) - if h.goView(modeStrings, "7"); len(h.m().stringsList) > 0 { + if h.goView(modeStrings, "7"); len(h.m().strs.List) > 0 { h.m().lastCopy = "" h.press("L") if h.m().lastCopy == "" { @@ -764,7 +764,7 @@ func TestKeysCopyLine(t *testing.T) { } } if h.goView(modeLibs, "8"); h.m().file.Info != nil && len(h.m().file.Info.DynamicLibs) > 0 { - h.m().buildLibRows() + h.m().libs.BuildRows(h.m().viewContext()) h.m().lastCopy = "" h.press("L") if h.m().lastCopy == "" { @@ -780,18 +780,18 @@ func TestStringsSortKeys(t *testing.T) { m := newTestModel(t, f) // Offset-ascending input (as the extractor produces), with addresses in a // different order so the address sort is observably distinct. - m.stringsList = []binfile.StringEntry{ + m.strs.List = []binfile.StringEntry{ {Offset: 0x10, Addr: 0x3000, HasAddr: true, Len: 1, Section: ".rodata"}, {Offset: 0x20, Addr: 0x1000, HasAddr: true, Len: 1, Section: ".rodata"}, {Offset: 0x30, Addr: 0x2000, HasAddr: true, Len: 1, Section: ".rodata"}, } m.setMode(modeStrings) - m.recomputeStrings() + m.strs.Recompute(m.viewContext()) offsets := func() []uint64 { var out []uint64 - for _, i := range m.stringsFiltered { - out = append(out, m.stringsList[i].Offset) + for _, i := range m.strs.Filtered { + out = append(out, m.strs.List[i].Offset) } return out } @@ -800,15 +800,15 @@ func TestStringsSortKeys(t *testing.T) { t.Fatalf("default (offset) order = %x", got) } // s → address: order becomes addr 0x1000,0x2000,0x3000 = offsets 0x20,0x30,0x10. - m.updateStrings("s") - if m.stringsSort != strSortAddr { - t.Fatalf("s did not advance sort, got %v", m.stringsSort) + m.strs.Update(m.viewContext(), m, "s") + if m.strs.Sort != strs.SortAddr { + t.Fatalf("s did not advance sort, got %v", m.strs.Sort) } if got := offsets(); got[0] != 0x20 || got[1] != 0x30 || got[2] != 0x10 { t.Fatalf("address-sorted order = %x", got) } // r reverses the address sort. - m.updateStrings("r") + m.strs.Update(m.viewContext(), m, "r") if got := offsets(); got[0] != 0x10 || got[2] != 0x20 { t.Fatalf("reversed address order = %x", got) } @@ -821,20 +821,20 @@ func TestLibsSortReverse(t *testing.T) { t.Skip("need >= 2 libraries") } first := func() string { - for _, r := range h.m().libsRows { - if r.node.leaf >= 0 { - return h.m().file.Info.DynamicLibs[r.node.leaf] + for _, r := range h.m().libs.Rows { + if r.Node.Leaf >= 0 { + return h.m().file.Info.DynamicLibs[r.Node.Leaf] } } return "" } // Flat list so the leaf order reflects the sort directly. - if h.m().libsTree { + if h.m().libs.Tree { h.press("t") } asc := first() h.press("r") - if !h.m().libsSortDesc { + if !h.m().libs.SortDesc { t.Fatal("r did not set descending") } if first() == asc { @@ -846,20 +846,20 @@ func TestSourcesSortKeys(t *testing.T) { bin := buildDebugSample(t) h := newKeyHarness(t, bin) h.goView(modeSources, "9") - if !h.m().file.HasDWARF() || len(h.m().sourcesFiles) == 0 { + if !h.m().file.HasDWARF() || len(h.m().sources.Files) == 0 { t.Skip("no DWARF sources") } - if h.m().sourcesTree { + if h.m().sources.Tree { h.press("t") } - s0 := h.m().sourcesSort + s0 := h.m().sources.Sort h.press("s") - if h.m().sourcesSort == s0 { + if h.m().sources.Sort == s0 || h.m().sources.Sort != sources.SortName { t.Fatal("s did not change the sources sort field") } - d0 := h.m().sourcesSortDesc + d0 := h.m().sources.SortDesc h.press("r") - if h.m().sourcesSortDesc == d0 { + if h.m().sources.SortDesc == d0 { t.Fatal("r did not reverse the sources sort") } } @@ -877,17 +877,17 @@ func TestKeysFacetChords(t *testing.T) { h.goView(modeSymbols, "3") // 1) Ctrl+t toggles the type facet filter. - h.m().symbolsKindOn = false + h.m().symbols.KindOn = false h.press("ctrl+t") - if !h.m().symbolsKindOn { + if !h.m().symbols.KindOn { t.Fatal("ctrl+t did not trigger the type filter") } // 2) Bare "t" must still toggle the tree, not filter (no regression). h.press("esc") - tree0 := h.m().symbolsTree + tree0 := h.m().symbols.Tree h.press("t") - if h.m().symbolsTree == tree0 { + if h.m().symbols.Tree == tree0 { t.Fatal("plain t no longer toggles the tree") } h.press("t") @@ -920,22 +920,22 @@ func TestKeysConfigurable(t *testing.T) { // Symbols: the configured sort key cycles the sort; the default "s" still works // too (aliases are additive), but the custom key must take effect. h.goView(modeSymbols, "3") - s0 := h.m().symbolsSort + s0 := h.m().symbols.Sort h.press("z") - if h.m().symbolsSort == s0 { + if h.m().symbols.Sort == s0 { t.Fatal("configured sort key 'z' did not change the sort") } - sc0 := h.m().symbolsScope + sc0 := h.m().symbols.Scope h.press("Q") - if h.m().symbolsScope == sc0 { + if h.m().symbols.Scope == sc0 { t.Fatal("configured scope-filter key 'Q' had no effect") } // Sections: the configured reverse key flips the order. h.goView(modeSections, "2") - d0 := h.m().sectionsSortDesc + d0 := h.m().sections.SortDesc h.press("x") - if h.m().sectionsSortDesc == d0 { + if h.m().sections.SortDesc == d0 { t.Fatal("configured reverse key 'x' did not reverse the sort") } @@ -972,8 +972,8 @@ func TestKeysActivate(t *testing.T) { // Strings: Enter opens the selected string (Hex if mapped, else Raw). h.goView(modeStrings, "7") - if len(h.m().stringsList) > 0 { - h.m().stringsCur = 0 + if len(h.m().strs.List) > 0 { + h.m().strs.Cur = 0 h.press("enter") if h.m().mode != modeHex && h.m().mode != modeRaw { t.Fatalf("strings Enter left mode = %v", h.m().mode) @@ -983,7 +983,7 @@ func TestKeysActivate(t *testing.T) { // Disasm: ←/→ walk history, Enter follows an operand address (tolerant: an // instruction may have no in-file target). h.goView(modeDisasm, "4") - if len(h.m().disasmInst) > 0 { + if len(h.m().dasm.Inst) > 0 { h.press("enter") // follow (or status "no address") h.press("left") // history back h.press("right") // history forward @@ -1011,11 +1011,11 @@ func TestKeysTabTogglesMode(t *testing.T) { h := newKeyHarness(t, systemBinary(t)) // Sections: tab toggles sections <-> segments, same as t. - if len(h.m().segments) > 0 { + if len(h.m().sections.Segments) > 0 { h.goView(modeSections, "2") - seg0 := h.m().showSegments + seg0 := h.m().sections.ShowSegments h.press("tab") - if h.m().showSegments == seg0 { + if h.m().sections.ShowSegments == seg0 { t.Fatal("tab did not toggle sections/segments") } } @@ -1023,18 +1023,18 @@ func TestKeysTabTogglesMode(t *testing.T) { // Symbols: tab toggles the tree. if len(h.m().file.Symbols) > 0 { h.goView(modeSymbols, "3") - tr0 := h.m().symbolsTree + tr0 := h.m().symbols.Tree h.press("tab") - if h.m().symbolsTree == tr0 { + if h.m().symbols.Tree == tr0 { t.Fatal("tab did not toggle the symbol tree") } } // Hex: tab toggles the ascii/pointer column. h.goView(modeHex, "5") - pw0 := h.m().hexNumeric + pw0 := h.m().byteViews.Numeric h.press("tab") - if h.m().hexNumeric == pw0 { + if h.m().byteViews.Numeric == pw0 { t.Fatal("tab did not toggle the hex pointer column") } @@ -1042,12 +1042,12 @@ func TestKeysTabTogglesMode(t *testing.T) { // it reports unavailable; with DWARF it toggles showSource. Either way the // view stays disasm and nothing panics. h.goView(modeDisasm, "4") - show0 := h.m().showSource + show0 := h.m().dasm.ShowSource h.press("tab") if h.m().mode != modeDisasm { t.Fatal("tab in disasm changed the view") } - if h.m().file.HasDWARF() && h.m().showSource == show0 { + if h.m().file.HasDWARF() && h.m().dasm.ShowSource == show0 { t.Fatal("tab in disasm did not toggle the source pane") } } @@ -1063,13 +1063,13 @@ func TestKeysEscClears(t *testing.T) { h.press("/") h.press("a") h.press("ctrl+t") - if h.m().symbolsFilter.Value() == "" && !h.m().symbolsKindOn { + if h.m().symbols.Filter.Value() == "" && !h.m().symbols.KindOn { t.Skip("could not set symbol filters") } h.press("esc") - if h.m().symbolsFilter.Value() != "" || h.m().symbolsKindOn || h.m().symbolsFilter.Focused() { + if h.m().symbols.Filter.Value() != "" || h.m().symbols.KindOn || h.m().symbols.Filter.Focused() { t.Fatalf("symbols esc did not clear everything (text=%q kind=%v focused=%v)", - h.m().symbolsFilter.Value(), h.m().symbolsKindOn, h.m().symbolsFilter.Focused()) + h.m().symbols.Filter.Value(), h.m().symbols.KindOn, h.m().symbols.Filter.Focused()) } } @@ -1079,19 +1079,19 @@ func TestKeysEscClears(t *testing.T) { h.press("/") h.press("t") h.press("esc") - if h.m().sectionsTypeOn || h.m().sectionsFilter.Value() != "" || h.m().sectionsFilter.Focused() { + if h.m().sections.TypeOn || h.m().sections.Filter.Value() != "" || h.m().sections.Filter.Focused() { t.Fatal("sections esc did not clear filters") } // Strings: section filter + text, esc clears. h.goView(modeStrings, "7") - if len(h.m().stringsSections) > 0 { + if len(h.m().strs.Sections) > 0 { h.press("ctrl+s") } h.press("/") h.press("a") h.press("esc") - if h.m().stringsSecOn || h.m().stringsFilter.Value() != "" { + if h.m().strs.SecOn || h.m().strs.Filter.Value() != "" { t.Fatal("strings esc did not clear filters") } @@ -1102,7 +1102,7 @@ func TestKeysEscClears(t *testing.T) { h.press("/") h.press("a") h.press("esc") - if h.m().libsAvail != availAll || h.m().libsFilter.Value() != "" { + if h.m().libs.Avail != view.AvailAll || h.m().libs.Filter.Value() != "" { t.Fatal("libs esc did not clear filters") } } @@ -1113,31 +1113,31 @@ func TestKeysEscClears(t *testing.T) { func TestKeysSectionFilters(t *testing.T) { h := newKeyHarness(t, systemBinary(t)) h.goView(modeSections, "2") - if len(h.m().sectionsTypes) == 0 { + if len(h.m().sections.Types) == 0 { t.Skip("no section type names") } - all := len(h.m().sectionsFiltered) + all := len(h.m().sections.Filtered) h.press("ctrl+t") - if !h.m().sectionsTypeOn { + if !h.m().sections.TypeOn { t.Fatal("ctrl+t did not enable the section type filter") } - if len(h.m().sectionsFiltered) > all { + if len(h.m().sections.Filtered) > all { t.Fatal("type filter did not narrow the list") } // Every visible row matches the selected type. - for _, idx := range h.m().sectionsFiltered { - if h.m().sections[idx].TypeName != h.m().sectionsType { - t.Fatalf("row type %q != filter %q", h.m().sections[idx].TypeName, h.m().sectionsType) + for _, idx := range h.m().sections.Filtered { + if h.m().sections.Sections[idx].TypeName != h.m().sections.TypeSel { + t.Fatalf("row type %q != filter %q", h.m().sections.Sections[idx].TypeName, h.m().sections.TypeSel) } } h.press("esc") - if h.m().sectionsTypeOn { + if h.m().sections.TypeOn { t.Fatal("esc did not clear the type filter") } // Flags filter is wired too. - if len(h.m().sectionsFlagsList) > 0 { + if len(h.m().sections.FlagsList) > 0 { h.press("ctrl+f") - if !h.m().sectionsFlagsOn { + if !h.m().sections.FlagsOn { t.Fatal("ctrl+f did not enable the section flags filter") } } @@ -1151,7 +1151,7 @@ func TestKeysLibsSearch(t *testing.T) { if h.m().file.Info == nil || len(h.m().file.Info.DynamicLibs) < 2 { t.Skip("need at least two dynamic libraries") } - all := len(h.m().libsRows) + all := len(h.m().libs.Rows) // Filter by a substring of the first library's basename. lib := h.m().file.Info.DynamicLibs[0] needle := lib @@ -1162,14 +1162,14 @@ func TestKeysLibsSearch(t *testing.T) { for _, r := range needle { h.press(string(r)) } - if h.m().libsFilter.Value() == "" { + if h.m().libs.Filter.Value() == "" { t.Skip("filter input did not accept text") } - if len(h.m().libsRows) > all { + if len(h.m().libs.Rows) > all { t.Fatal("libs search did not narrow the list") } h.press("esc") - if h.m().libsFilter.Value() != "" { + if h.m().libs.Filter.Value() != "" { t.Fatal("esc did not clear the libs search") } } @@ -1185,21 +1185,21 @@ func TestKeysNavAliases(t *testing.T) { // ctrl+down / alt+down page down; cmd+up returns to the top. h.press("ctrl+down") - if h.m().sectionsCur == 0 { + if h.m().sections.Cur == 0 { t.Fatal("ctrl+down did not page down") } h.press("cmd+up") - if h.m().sectionsCur != 0 { + if h.m().sections.Cur != 0 { t.Fatal("cmd+up did not go to the top") } // cmd+down goes to the bottom; ctrl+up pages back up. h.press("cmd+down") - bottom := h.m().sectionsCur - if bottom != len(h.m().sectionsFiltered)-1 { - t.Fatalf("cmd+down landed at %d, want %d", bottom, len(h.m().sectionsFiltered)-1) + bottom := h.m().sections.Cur + if bottom != len(h.m().sections.Filtered)-1 { + t.Fatalf("cmd+down landed at %d, want %d", bottom, len(h.m().sections.Filtered)-1) } h.press("ctrl+up") - if h.m().sectionsCur == bottom { + if h.m().sections.Cur == bottom { t.Fatal("ctrl+up did not page up") } } diff --git a/internal/ui/keyglyphs.go b/internal/ui/keyglyphs.go index b191e70..46aa17b 100644 --- a/internal/ui/keyglyphs.go +++ b/internal/ui/keyglyphs.go @@ -4,14 +4,4 @@ package ui // filters use Ctrl chords (the same on macOS and Linux) — Alt was dropped because // gnome-terminal binds Alt+letter to its menubar mnemonics, swallowing the keys. -import "strings" - -// ctrlKeys renders a Ctrl-chord list as "^t" / "^t/^f": each key gets a caret, -// joined with "/". Compact and identical on every platform. -func ctrlKeys(keys ...string) string { - parts := make([]string, len(keys)) - for i, k := range keys { - parts[i] = "^" + k - } - return strings.Join(parts, "/") -} +// ctrlKeys moved to internal/ui/layout as layout.CtrlKeys. diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 75b95cd..a6152c9 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -31,6 +31,9 @@ const ( actionBack // pop the cross-file stack (return to the file we came from) actionCPUFeatures // scan & show the CPU features the binary requires actionHeader // show the raw container-header overlay + actionJumpCaret // open the caret's address in another view (cross-nav modal) + actionFindCaret // search the binary for the value under the caret (find modal) + actionFindQuery // free-text global content search (find results modal) ) // keyMap maps key strings (as returned by tea.KeyMsg.String()) to the @@ -59,6 +62,10 @@ func defaultKeyMap() keyMap { "ctrl+o": actionBack, "F": actionCPUFeatures, "H": actionHeader, + ">": actionJumpCaret, + "space": actionJumpCaret, // open the caret address in another view + "f": actionFindCaret, // search the binary for the value under the caret + "l": actionFindQuery, // free-text global content search } } diff --git a/internal/ui/keymap27_test.go b/internal/ui/keymap27_test.go index b16d46c..5b97546 100644 --- a/internal/ui/keymap27_test.go +++ b/internal/ui/keymap27_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/views/sections" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) func newTestModel(t *testing.T, f *binfile.File) *Model { @@ -26,14 +28,14 @@ func TestSectionsSortKeys(t *testing.T) { {Name: "mid", Addr: 0x2000, Size: 30}, }} m := newTestModel(t, f) - m.sections = f.Sections + m.sections.Sections = f.Sections m.setMode(modeSections) - m.recomputeSections() + m.sections.Recompute() order := func() []string { var out []string - for _, i := range m.sectionsFiltered { - out = append(out, m.sections[i].Name) + for _, i := range m.sections.Filtered { + out = append(out, m.sections.Sections[i].Name) } return out } @@ -42,28 +44,28 @@ func TestSectionsSortKeys(t *testing.T) { t.Fatalf("index order = %v", got) } // s: index → name. - m.updateSections("s") - if m.sectionsSort != secSortName { - t.Fatalf("sort after s = %v, want name", m.sectionsSort) + m.sections.Update(m.viewContext(), m, "s") + if m.sections.Sort != sections.SortName { + t.Fatalf("sort after s = %v, want name", m.sections.Sort) } if got := order(); got[0] != "alpha" || got[2] != "zeta" { t.Fatalf("name order = %v, want [alpha mid zeta]", got) } // s: name → address. - m.updateSections("s") + m.sections.Update(m.viewContext(), m, "s") if got := order(); got[0] != "alpha" || got[2] != "zeta" { // addr 0x1000<0x2000<0x3000 t.Fatalf("address order = %v", got) } // r: reverse the address sort. - m.updateSections("r") - if !m.sectionsSortDesc { + m.sections.Update(m.viewContext(), m, "r") + if !m.sections.SortDesc { t.Fatal("r did not set descending") } if got := order(); got[0] != "zeta" || got[2] != "alpha" { t.Fatalf("reversed address order = %v", got) } // s: address → size (still descending: 50,30,10 → alpha,mid,zeta). - m.updateSections("s") + m.sections.Update(m.viewContext(), m, "s") if got := order(); got[0] != "alpha" || got[2] != "zeta" { t.Fatalf("size desc order = %v", got) } @@ -72,34 +74,34 @@ func TestSectionsSortKeys(t *testing.T) { func TestStringsSectionFilter(t *testing.T) { f := &binfile.File{Format: binfile.FormatELF} m := newTestModel(t, f) - m.stringsList = []binfile.StringEntry{ + m.strs.List = []binfile.StringEntry{ {Offset: 0x10, Len: 3, Section: ".rodata"}, {Offset: 0x20, Len: 3, Section: ".data"}, {Offset: 0x30, Len: 3, Section: ".rodata"}, } - m.buildStringSections() + m.strs.BuildSections() m.setMode(modeStrings) - m.recomputeStrings() + m.strs.Recompute(m.viewContext()) - if len(m.stringsFiltered) != 3 { - t.Fatalf("unfiltered = %d, want 3", len(m.stringsFiltered)) + if len(m.strs.Filtered) != 3 { + t.Fatalf("unfiltered = %d, want 3", len(m.strs.Filtered)) } // ctrl+s turns the section filter on at the first section (.data, sorted). - m.updateStrings("ctrl+s") - if !m.stringsSecOn || m.stringsSec != ".data" { - t.Fatalf("after ctrl+s: on=%v sec=%q", m.stringsSecOn, m.stringsSec) + m.strs.Update(m.viewContext(), m, "ctrl+s") + if !m.strs.SecOn || m.strs.Sec != ".data" { + t.Fatalf("after ctrl+s: on=%v sec=%q", m.strs.SecOn, m.strs.Sec) } - if len(m.stringsFiltered) != 1 { - t.Fatalf(".data filter = %d, want 1", len(m.stringsFiltered)) + if len(m.strs.Filtered) != 1 { + t.Fatalf(".data filter = %d, want 1", len(m.strs.Filtered)) } // ctrl+s again → .rodata (2 entries). - m.updateStrings("ctrl+s") - if m.stringsSec != ".rodata" || len(m.stringsFiltered) != 2 { - t.Fatalf(".rodata filter: sec=%q n=%d", m.stringsSec, len(m.stringsFiltered)) + m.strs.Update(m.viewContext(), m, "ctrl+s") + if m.strs.Sec != ".rodata" || len(m.strs.Filtered) != 2 { + t.Fatalf(".rodata filter: sec=%q n=%d", m.strs.Sec, len(m.strs.Filtered)) } // ctrl+s past the last section → off. - m.updateStrings("ctrl+s") - if m.stringsSecOn { + m.strs.Update(m.viewContext(), m, "ctrl+s") + if m.strs.SecOn { t.Fatal("filter should be off after cycling past the last section") } } @@ -111,43 +113,43 @@ func TestSymbolsKeyMigration(t *testing.T) { }} m := newTestModel(t, f) m.setMode(modeSymbols) - m.recomputeSymbols() + m.symbols.Recompute(m.viewContext()) // s now cycles the sort field (was o); o is no longer a sort key. - if m.symbolsSort != sortByName { - t.Fatalf("precondition sort = %v", m.symbolsSort) + if m.symbols.Sort != symbols.SortName { + t.Fatalf("precondition sort = %v", m.symbols.Sort) } - m.updateSymbols("o") - if m.symbolsSort != sortByName { + m.symbols.Update(m.viewContext(), m, "o") + if m.symbols.Sort != symbols.SortName { t.Fatal("o should no longer change the sort") } - m.updateSymbols("s") - if m.symbolsSort != sortByAddr { - t.Fatalf("s did not advance sort, got %v", m.symbolsSort) + m.symbols.Update(m.viewContext(), m, "s") + if m.symbols.Sort != symbols.SortAddr { + t.Fatalf("s did not advance sort, got %v", m.symbols.Sort) } // ⌥t cycles the type filter (was y); y is no longer a filter key. - m.updateSymbols("y") - if m.symbolsKindOn { + m.symbols.Update(m.viewContext(), m, "y") + if m.symbols.KindOn { t.Fatal("y should no longer toggle the type filter") } - m.updateSymbols("ctrl+t") - if !m.symbolsKindOn { + m.symbols.Update(m.viewContext(), m, "ctrl+t") + if !m.symbols.KindOn { t.Fatal("ctrl+t did not enable the type filter") } // ⌥s cycles scope. - if m.symbolsScope != scopeAll { - t.Fatalf("precondition scope = %v", m.symbolsScope) + if m.symbols.Scope != symbols.ScopeAll { + t.Fatalf("precondition scope = %v", m.symbols.Scope) } - m.updateSymbols("ctrl+s") - if m.symbolsScope == scopeAll { + m.symbols.Update(m.viewContext(), m, "ctrl+s") + if m.symbols.Scope == symbols.ScopeAll { t.Fatal("ctrl+s did not advance the scope filter") } // Esc clears every filter. - m.updateSymbols("esc") - if m.symbolsKindOn || m.symbolsScope != scopeAll { - t.Fatalf("esc did not clear filters: kind=%v scope=%v", m.symbolsKindOn, m.symbolsScope) + m.symbols.Update(m.viewContext(), m, "esc") + if m.symbols.KindOn || m.symbols.Scope != symbols.ScopeAll { + t.Fatalf("esc did not clear filters: kind=%v scope=%v", m.symbols.KindOn, m.symbols.Scope) } } diff --git a/internal/ui/layout/keys.go b/internal/ui/layout/keys.go new file mode 100644 index 0000000..2a4aa6e --- /dev/null +++ b/internal/ui/layout/keys.go @@ -0,0 +1,39 @@ +package layout + +import "strings" + +// CtrlKeys renders a Ctrl-chord list as "^t" / "^t/^f": each key gets a caret, +// joined with "/". Compact and identical on every platform. +func CtrlKeys(keys ...string) string { + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = "^" + k + } + return strings.Join(parts, "/") +} + +// AppendAddr writes "0x" + addr, zero-padded to digits hex chars, into dst — +// what fmt.Sprintf("0x%0*x", digits, addr) produces, without fmt's boxing and +// its intermediate string. The hex and disasm views format an address for every +// visible row, on every frame. +func AppendAddr(dst []byte, addr uint64, digits int) []byte { + const hexDigits = "0123456789abcdef" + var tmp [16]byte // a uint64 is at most 16 hex digits + n := 0 + for { + tmp[n] = hexDigits[addr&0xf] + n++ + addr >>= 4 + if addr == 0 { + break + } + } + dst = append(dst, '0', 'x') + for i := n; i < digits; i++ { + dst = append(dst, '0') + } + for i := n - 1; i >= 0; i-- { + dst = append(dst, tmp[i]) + } + return dst +} diff --git a/internal/ui/layout/list.go b/internal/ui/layout/list.go new file mode 100644 index 0000000..f14763b --- /dev/null +++ b/internal/ui/layout/list.go @@ -0,0 +1,186 @@ +package layout + +// List/table interaction primitives shared by the views: page-size measurement, +// standard cursor navigation, case-insensitive substring filtering, facet +// cycling, and a bounded per-row render memo. All Model-independent, so a view +// package can build a filterable, scrollable table without importing ui. + +// PageStep returns how many list items make up one screen "page": the number of +// items whose stacked rendered heights fill visibleLines, starting at item from +// (the current top of the viewport), clamped to at least 1. rowHeight gives each +// item's line count (1 for single-line rows). Paging by this many items advances +// the view by about one screen instead of overshooting when rows carry chrome +// (header/filter lines reduce visibleLines) or wrap onto multiple lines. +func PageStep(from, n, visibleLines int, rowHeight func(int) int) int { + if visibleLines < 1 { + visibleLines = 1 + } + used, count := 0, 0 + for i := from; i < n; i++ { + h := rowHeight(i) + if h < 1 { + h = 1 + } + if count > 0 && used+h > visibleLines { + break + } + used += h + count++ + if used >= visibleLines { + break + } + } + if count < 1 { + count = 1 + } + return count +} + +// NavKey applies a standard list-navigation key (up/down/k/j, pgup/pgdown, +// home/end/G) to a cursor over n items, paging by page rows. It returns true +// when it consumed the key (so the caller can stop), and always leaves the +// cursor in [0, n-1] — or 0 for an empty list. `[`/`]` page up/down here too: +// only the list views route through NavKey (disasm/hex/source-open handle those +// keys themselves), so paging with them is free in exactly the list views. +func NavKey(cur *int, n, page int, key string) bool { + switch key { + case "up", "k": + *cur-- + case "down", "j": + *cur++ + case "pgup", "[": + *cur -= page + case "pgdown", "]": + *cur += page + case "home": + *cur = 0 + case "end", "G": + *cur = n - 1 + default: + return false + } + if *cur >= n { + *cur = n - 1 + } + if *cur < 0 { + *cur = 0 + } + return true +} + +// ContainsFold reports whether s contains substr, ASCII case-insensitively, +// without the allocation that strings.Contains(strings.ToLower(s), substr) costs +// per call. The list filters run this over every row on every keystroke, so the +// allocation matters on large tables (the Strings/Symbols views). substr is +// expected already-lowercased by the caller; non-ASCII bytes compare exactly, +// which is fine for the identifier/path/section text these filters match. +func ContainsFold(s, substr string) bool { + if substr == "" { + return true + } + for i := 0; i+len(substr) <= len(s); i++ { + if hasFoldPrefixLower(s[i:], substr) { + return true + } + } + return false +} + +// hasFoldPrefixLower reports whether s starts with prefix, where prefix is +// already lowercased and s is folded to lower as it is compared. +func hasFoldPrefixLower(s, prefix string) bool { + for i := 0; i < len(prefix); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + if c != prefix[i] { + return false + } + } + return true +} + +// ContainsFoldBytes is ContainsFold over a byte slice (substr already lowercased), +// so the strings filter can scan zero-copy slices into the file image without +// allocating a string per entry. +func ContainsFoldBytes(b []byte, substr string) bool { + if substr == "" { + return true + } + for i := 0; i+len(substr) <= len(b); i++ { + match := true + for j := 0; j < len(substr); j++ { + c := b[i+j] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + if c != substr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// CycleStringList advances a facet filter through list: off → first → … → last → +// off. *on tracks whether the filter is active, *cur its current value. +func CycleStringList(on *bool, cur *string, list []string) { + if len(list) == 0 { + return + } + if !*on { + *on, *cur = true, list[0] + return + } + for i, v := range list { + if v == *cur { + if i == len(list)-1 { + *on, *cur = false, "" + } else { + *cur = list[i+1] + } + return + } + } + *on, *cur = true, list[0] +} + +// RowMemoCap bounds each RowMemo so a long scroll through a huge table (millions +// of symbol/string rows) can't grow the cache for the whole session. When full +// the memo is flushed wholesale — cheaper than per-entry eviction, and the +// visible window repopulates in one render pass. +const RowMemoCap = 4096 + +// RowMemo is a lazily-allocated, bounded memo cache for rendered rows (or their +// heights), keyed by K. It centralises the "nil-check → lookup → build → store" +// pattern the list/disasm renderers would otherwise repeat by hand. The zero +// value (nil map) is ready to use. +type RowMemo[K comparable, V any] map[K]V + +// Get returns the cached value for key, building and caching it on a miss. +func (m *RowMemo[K, V]) Get(key K, build func() V) V { + if *m == nil { + *m = make(RowMemo[K, V]) + } else if v, ok := (*m)[key]; ok { + return v + } + if len(*m) >= RowMemoCap { + clear(*m) + } + v := build() + (*m)[key] = v + return v +} + +// ReverseInts reverses s in place — the cheap path for flipping a list already +// in its natural ascending order to descending. +func ReverseInts(s []int) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} diff --git a/internal/ui/layout/list_test.go b/internal/ui/layout/list_test.go new file mode 100644 index 0000000..3a8d386 --- /dev/null +++ b/internal/ui/layout/list_test.go @@ -0,0 +1,95 @@ +package layout + +import "testing" + +func TestNavKey(t *testing.T) { + cur := 5 + // down/up move by one, clamped to [0, n-1]. + if !NavKey(&cur, 10, 4, "down") || cur != 6 { + t.Fatalf("down: cur=%d", cur) + } + if !NavKey(&cur, 10, 4, "up") || cur != 5 { + t.Fatalf("up: cur=%d", cur) + } + // paging clamps at the ends. + NavKey(&cur, 10, 4, "pgdown") + if cur != 9 { + t.Fatalf("pgdown clamp: cur=%d, want 9", cur) + } + NavKey(&cur, 10, 4, "home") + if cur != 0 { + t.Fatalf("home: cur=%d", cur) + } + NavKey(&cur, 10, 4, "end") + if cur != 9 { + t.Fatalf("end: cur=%d", cur) + } + // `[`/`]` page too; unknown keys are not consumed. + if NavKey(&cur, 10, 4, "x") { + t.Fatalf("unexpectedly consumed unknown key") + } + // empty list stays at 0. + cur = 0 + NavKey(&cur, 0, 4, "down") + if cur != 0 { + t.Fatalf("empty list: cur=%d", cur) + } +} + +func TestContainsFold(t *testing.T) { + if !ContainsFold("HelloWorld", "world") { + t.Error("case-insensitive substring not found") + } + if ContainsFold("abc", "xyz") { + t.Error("false positive") + } + if !ContainsFold("anything", "") { + t.Error("empty needle should match") + } + if !ContainsFoldBytes([]byte("libSYSTEM.dylib"), "system") { + t.Error("byte variant case-insensitive match failed") + } +} + +func TestCycleStringList(t *testing.T) { + on, cur := false, "" + list := []string{"a", "b", "c"} + CycleStringList(&on, &cur, list) // off -> first + if !on || cur != "a" { + t.Fatalf("first: on=%v cur=%q", on, cur) + } + CycleStringList(&on, &cur, list) // a -> b + CycleStringList(&on, &cur, list) // b -> c + if cur != "c" { + t.Fatalf("advance: cur=%q, want c", cur) + } + CycleStringList(&on, &cur, list) // c -> off + if on { + t.Fatalf("last should turn off, on=%v", on) + } + // empty list is a no-op. + on, cur = true, "keep" + CycleStringList(&on, &cur, nil) + if !on || cur != "keep" { + t.Fatalf("empty list mutated state: on=%v cur=%q", on, cur) + } +} + +func TestRowMemo(t *testing.T) { + var m RowMemo[int, string] + calls := 0 + build := func() string { calls++; return "v" } + if got := m.Get(1, build); got != "v" || calls != 1 { + t.Fatalf("first get: %q calls=%d", got, calls) + } + if got := m.Get(1, build); got != "v" || calls != 1 { + t.Fatalf("cached get rebuilt: calls=%d", calls) + } + // Overflow flushes wholesale, then repopulates. + for i := 0; i < RowMemoCap+1; i++ { + m.Get(1000+i, func() string { return "x" }) + } + if len(m) > RowMemoCap { + t.Fatalf("memo exceeded cap: len=%d", len(m)) + } +} diff --git a/internal/ui/layout/overlay.go b/internal/ui/layout/overlay.go new file mode 100644 index 0000000..eb905d5 --- /dev/null +++ b/internal/ui/layout/overlay.go @@ -0,0 +1,41 @@ +package layout + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Overlay places fg over bg at column x, row y. Both are pre-rendered strings. +// It is ANSI- and width-aware: the background to the left and right of the +// modal keeps its colours and lines up correctly even when those lines contain +// styled or multi-byte content (e.g. the disasm source-pane border). +func Overlay(bg, fg string, x, y int) string { + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + bgLines := strings.Split(bg, "\n") + fgLines := strings.Split(fg, "\n") + for i, fl := range fgLines { + row := y + i + if row >= len(bgLines) { + break + } + bgLine := bgLines[row] + fw := ansi.StringWidth(fl) + + // Left slice: the first x cells of the background, padded if short. + left := ansi.Truncate(bgLine, x, "") + if w := ansi.StringWidth(left); w < x { + left += strings.Repeat(" ", x-w) + } + // Right slice: the background beyond the modal, with its style preserved. + right := ansi.TruncateLeft(bgLine, x+fw, "") + + bgLines[row] = left + "\x1b[0m" + fl + "\x1b[0m" + right + } + return strings.Join(bgLines, "\n") +} diff --git a/internal/ui/overlay_test.go b/internal/ui/layout/overlay_test.go similarity index 96% rename from internal/ui/overlay_test.go rename to internal/ui/layout/overlay_test.go index d5f7e24..718b857 100644 --- a/internal/ui/overlay_test.go +++ b/internal/ui/layout/overlay_test.go @@ -1,4 +1,4 @@ -package ui +package layout import ( "strings" @@ -19,7 +19,7 @@ func TestOverlayPreservesBackground(t *testing.T) { bg := strings.Join([]string{bgLine, bgLine, bgLine}, "\n") modal := "ABCDE\nFGHIJ" // 2 lines, width 5, no styling - out := overlay(bg, modal, 10, 1) + out := Overlay(bg, modal, 10, 1) lines := strings.Split(out, "\n") // Row 0 is untouched. diff --git a/internal/ui/layout/painter_test.go b/internal/ui/layout/painter_test.go new file mode 100644 index 0000000..26978bb --- /dev/null +++ b/internal/ui/layout/painter_test.go @@ -0,0 +1,42 @@ +package layout + +import ( + "testing" + + "charm.land/lipgloss/v2" +) + +// TestPainterTextMatchesLipgloss pins Painter.Text to the bytes lipgloss itself +// produces. Text exists to skip the render, so it is only correct while it is +// indistinguishable from one — including the exact spelling of the closing +// reset, which the disasm selection bar rewrites by substitution. A lipgloss +// upgrade that changed it would otherwise surface as an unexplained golden diff. +func TestPainterTextMatchesLipgloss(t *testing.T) { + styles := map[string]lipgloss.Style{ + "foreground": lipgloss.NewStyle().Foreground(lipgloss.Color("#79808f")), + "background": lipgloss.NewStyle().Background(lipgloss.Color("#5e81ac")), + "bold foreground": lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#d8dee9")), + "fg + bg": lipgloss.NewStyle().Foreground(lipgloss.Color("#eceff4")).Background(lipgloss.Color("#3b4252")), + "underline": lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("#88c0d0")), + "no styling": lipgloss.NewStyle(), + } + for name, st := range styles { + t.Run(name, func(t *testing.T) { + p := NewPainter(st) + for _, s := range []string{"0x0000000000401000", "main", "x"} { + if got, want := p.Text(s), st.Render(s); got != want { + t.Errorf("Text(%q)\n got %q\n want %q (lipgloss)", s, got, want) + } + } + }) + } +} + +// TestPainterTextLeavesEmptyAlone: lipgloss renders "" as "", with no escape +// codes; Text must not emit a bare colour sequence for it. +func TestPainterTextLeavesEmptyAlone(t *testing.T) { + p := NewPainter(lipgloss.NewStyle().Foreground(lipgloss.Color("#79808f"))) + if got := p.Text(""); got != "" { + t.Errorf("Text(\"\") = %q, want the empty string", got) + } +} diff --git a/internal/ui/layout/scroll.go b/internal/ui/layout/scroll.go new file mode 100644 index 0000000..20317b0 --- /dev/null +++ b/internal/ui/layout/scroll.go @@ -0,0 +1,115 @@ +// Package layout holds the pure, Model-independent scroll/viewport geometry the +// TUI views share: given a cursor, a window height and a per-row height function, +// it computes which row to anchor at the top and which logical item sits at a +// visual row. Keeping it dependency-free (no Model, no lipgloss) makes the +// variable-height scroll math unit-testable in isolation and is the first seam +// carved out of the large ui package. +package layout + +// ViewportTop clamps a detached viewport top for variable-height rows. +func ViewportTop(top, n, visible int, rowHeight func(int) int) int { + if n <= 0 { + return 0 + } + if visible < 1 { + visible = 1 + } + if top < 0 { + top = 0 + } + if top >= n { + top = n - 1 + } + maxTop := MaxViewportTop(n, visible, rowHeight) + if top > maxTop { + return maxTop + } + return top +} + +// MaxViewportTop returns the latest top row that can fill the viewport. +func MaxViewportTop(n, visible int, rowHeight func(int) int) int { + if n <= 0 { + return 0 + } + if visible < 1 { + visible = 1 + } + rows := 0 + top := n + for top > 0 { + h := max(1, rowHeight(top-1)) + if rows+h > visible { + break + } + rows += h + top-- + } + if top == n { + return n - 1 + } + return top +} + +// VisualTop returns the nearest top that keeps cur visible. +func VisualTop(cur, top, n, visible int, rowHeight func(int) int) int { + if n <= 0 { + return 0 + } + if visible < 1 { + visible = 1 + } + if cur < 0 { + cur = 0 + } + if cur >= n { + cur = n - 1 + } + if top < 0 || cur < top { + top = cur + } + if top >= n { + top = n - 1 + } + if maxTop := MaxViewportTop(n, visible, rowHeight); top > maxTop { + top = maxTop + } + if top > cur { + top = cur + } + + // Find the earliest row that can still keep cur visible by walking backward + // only as far as the viewport can fit. This preserves the old top while it's + // valid, but avoids the O(n²) forward scan when the cursor jumps far away + // (End / Ctrl+E on huge symbol or string tables). + minTop := cur + rows := max(1, rowHeight(cur)) + for minTop > 0 { + h := max(1, rowHeight(minTop-1)) + if rows+h > visible { + break + } + rows += h + minTop-- + } + if top < minTop { + top = minTop + } + return top +} + +// VisualItemAtRow maps a visual row offset to a logical item index. +func VisualItemAtRow(top, n, row int, rowHeight func(int) int) (int, bool) { + if row < 0 { + return 0, false + } + pos := 0 + for i := top; i < n; i++ { + h := max(1, rowHeight(i)) + if row >= pos && row < pos+h { + return i, true + } + pos += h + } + return 0, false +} diff --git a/internal/ui/layout/scroll_test.go b/internal/ui/layout/scroll_test.go new file mode 100644 index 0000000..71fa9e2 --- /dev/null +++ b/internal/ui/layout/scroll_test.go @@ -0,0 +1,82 @@ +package layout + +import "testing" + +// fixed makes a constant row-height function. +func fixed(h int) func(int) int { return func(int) int { return h } } + +func TestMaxViewportTopUniform(t *testing.T) { + // 10 rows, height 1, viewport of 3 → the last top that fills the window is 7. + if got := MaxViewportTop(10, 3, fixed(1)); got != 7 { + t.Fatalf("MaxViewportTop = %d, want 7", got) + } + // Everything fits → top 0. + if got := MaxViewportTop(2, 5, fixed(1)); got != 0 { + t.Fatalf("MaxViewportTop(fits) = %d, want 0", got) + } + if got := MaxViewportTop(0, 3, fixed(1)); got != 0 { + t.Fatalf("MaxViewportTop(empty) = %d, want 0", got) + } +} + +func TestVisualTopKeepsCursorVisible(t *testing.T) { + rh := fixed(1) + // Cursor above the current top pulls the top up to the cursor. + if got := VisualTop(2, 5, 20, 4, rh); got > 2 { + t.Fatalf("VisualTop above window = %d, want <= 2", got) + } + // Cursor far below scrolls down but never past MaxViewportTop. + top := VisualTop(19, 0, 20, 4, rh) + if max := MaxViewportTop(20, 4, rh); top > max { + t.Fatalf("VisualTop = %d exceeds MaxViewportTop %d", top, max) + } + if top < 19-4+1 { + t.Fatalf("VisualTop = %d does not keep cursor 19 visible in height 4", top) + } +} + +func TestVisualTopVariableHeights(t *testing.T) { + // Row 0 is tall (3), the rest are 1. A viewport of 3 can't show row 0 plus the + // cursor at row 2, so the top must advance off the tall row. + rh := func(i int) int { + if i == 0 { + return 3 + } + return 1 + } + if got := VisualTop(2, 0, 5, 3, rh); got == 0 { + t.Fatalf("VisualTop kept the oversized top row; want it scrolled past, got %d", got) + } +} + +func TestVisualItemAtRow(t *testing.T) { + // Heights [1,2,1]: visual rows 0→item0, 1→item1, 2→item1, 3→item2. + rh := func(i int) int { + if i == 1 { + return 2 + } + return 1 + } + cases := map[int]int{0: 0, 1: 1, 2: 1, 3: 2} + for row, want := range cases { + if got, ok := VisualItemAtRow(0, 3, row, rh); !ok || got != want { + t.Fatalf("VisualItemAtRow(row=%d) = %d ok=%v, want %d", row, got, ok, want) + } + } + if _, ok := VisualItemAtRow(0, 3, -1, rh); ok { + t.Fatalf("VisualItemAtRow(-1) ok=true, want false") + } + if _, ok := VisualItemAtRow(0, 3, 99, rh); ok { + t.Fatalf("VisualItemAtRow(past end) ok=true, want false") + } +} + +func TestViewportTopClamps(t *testing.T) { + rh := fixed(1) + if got := ViewportTop(-5, 10, 3, rh); got != 0 { + t.Fatalf("ViewportTop(negative) = %d, want 0", got) + } + if got, max := ViewportTop(100, 10, 3, rh), MaxViewportTop(10, 3, rh); got != max { + t.Fatalf("ViewportTop(overshoot) = %d, want %d", got, max) + } +} diff --git a/internal/ui/layout/sortheader.go b/internal/ui/layout/sortheader.go new file mode 100644 index 0000000..7395cc2 --- /dev/null +++ b/internal/ui/layout/sortheader.go @@ -0,0 +1,90 @@ +package layout + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// Sort-header primitives shared by the table views: labelling the active sort +// column with a direction triangle, mapping a click x-position to a column, and +// the htop-style "click to sort / click again to reverse" toggle. All generic and +// Model-independent, so any view (in ui or its own package) sorts a table the +// same way. + +// SortableHeaderCol is the clickable x-range [start,end) of one sortable column, +// tagged with the sort key it selects. +type SortableHeaderCol[T comparable] struct { + Start, End int + Sort T +} + +// SortHeaderLabel returns label with a direction triangle appended when it is the +// active sort column, right-padded to width; otherwise the plain label. +func SortHeaderLabel[T comparable](label string, width int, sort, active T, desc bool) string { + if sort != active { + return label + } + return ActiveSortHeaderLabel(label, width, desc) +} + +// ActiveSortHeaderLabel appends the direction triangle to an always-active header +// label (a single-column table with no per-column sort key), right-padded to +// width. +func ActiveSortHeaderLabel(label string, width int, desc bool) string { + triangle := sortTriangle(desc) + labelW := lipgloss.Width(label) + triangleW := lipgloss.Width(triangle) + if width <= labelW+triangleW { + return label + triangle + } + return label + strings.Repeat(" ", width-labelW-triangleW) + triangle +} + +// TrailingSortHeaderLabel is SortHeaderLabel for a trailing column that isn't +// padded to a fixed width (the triangle follows immediately). +func TrailingSortHeaderLabel[T comparable](label string, sort, active T, desc bool) string { + if sort != active { + return label + } + return ActiveSortHeaderLabel(label, lipgloss.Width(label)+3, desc) +} + +func sortTriangle(desc bool) string { + if desc { + return "▽" + } + return "△" +} + +// HitSortableHeader returns the sort key of the column containing x, if any. +func HitSortableHeader[T comparable](cols []SortableHeaderCol[T], x int) (T, bool) { + for _, col := range cols { + if x >= col.Start && x < col.End { + return col.Sort, true + } + } + var zero T + return zero, false +} + +// ApplySortHeaderClick mirrors htop-style header sorting: choosing a different +// column sorts it ascending; clicking the active column reverses the direction. +// It returns whether the sort *field* changed (vs. only the direction). +func ApplySortHeaderClick[T comparable](active *T, desc *bool, sort T) bool { + if *active == sort { + *desc = !*desc + return false + } + *active = sort + *desc = false + return true +} + +// SortDirectionLabel is the human-readable name of a sort direction. +func SortDirectionLabel(desc bool) string { + if desc { + return "descending" + } + return "ascending" +} diff --git a/internal/ui/layout/style.go b/internal/ui/layout/style.go new file mode 100644 index 0000000..8b3fb23 --- /dev/null +++ b/internal/ui/layout/style.go @@ -0,0 +1,98 @@ +package layout + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// StylePrefix returns the leading SGR sequence a lipgloss style emits (its "open" +// codes), or "" when the style adds no styling. +// +// It costs a full lipgloss render, so anything on a render path should build a +// Painter once instead of calling this per frame. +func StylePrefix(st lipgloss.Style) string { + sample := st.Render("x") + i := strings.IndexByte(sample, 'x') + if i <= 0 { + return "" + } + return sample[:i] +} + +// A Painter applies a style's colour, holding the style's SGR sequence rather +// than re-deriving it. +// +// Deriving it means rendering a sample string through lipgloss, which serialises +// the colours into ANSI and allocates — and the callers here (the table header, +// the panel background behind every frame, an address on every disasm row) run +// on every redraw. The zero Painter adds nothing and passes text through +// unchanged. +type Painter struct { + prefix string + style lipgloss.Style + // simple records that wrapping the text in prefix + reset reproduces what + // lipgloss would emit. Not every style is like that — an underline style is + // rendered character by character — so Text falls back to the real render for + // the ones that aren't, and no theme can silently change what is drawn. + simple bool +} + +// NewPainter captures st's SGR sequence. Build it when the theme is built, not +// when a frame is drawn. +func NewPainter(st lipgloss.Style) Painter { + p := Painter{prefix: StylePrefix(st), style: st} + // Verify against lipgloss itself rather than reasoning about which style + // properties are safe: whatever the library does, Text matches it. + const probe = "ab" + p.simple = p.prefix != "" && p.prefix+probe+"\x1b[m" == st.Render(probe) + return p +} + +// Text applies the painter's colour to a single run of *plain* text — byte for +// byte what a lipgloss render of the same style produces, but without the +// render for the styles that allow it. +// +// Only for text that carries no styling of its own: an embedded reset would end +// the colour early, where lipgloss would re-arm it. Use Fill for a line that has +// styled spans inside it. +func (p Painter) Text(s string) string { + if s == "" || p.prefix == "" { + return s + } + if !p.simple { + return p.style.Render(s) + } + // lipgloss's short reset (ESC[m), not ESC[0m: equivalent to a terminal, but + // the disasm selection bar re-arms itself by substituting the resets it finds, + // so the spelling has to be the one it expects. + return p.prefix + s + "\x1b[m" +} + +// Fill applies the painter's colour across every line of s, padding each to w +// columns and re-arming the colour after the per-span resets inside it, so the +// fill survives the styles nested in the line. A painter that adds nothing +// returns s unchanged. +func (p Painter) Fill(s string, w int) string { + if p.prefix == "" { + return s + } + lines := strings.Split(s, "\n") + for i, line := range lines { + if w > 0 && lipgloss.Width(line) != w { + line = PadRight(line, w) + } + line = strings.ReplaceAll(line, "\x1b[0m", "\x1b[0m"+p.prefix) + line = strings.ReplaceAll(line, "\x1b[m", "\x1b[m"+p.prefix) + lines[i] = p.prefix + line + "\x1b[0m" + } + return strings.Join(lines, "\n") +} + +// RenderStyle is Fill with the painter built on the spot, for callers that do +// not hold one. It skips NewPainter's probe render: Fill never consults `simple`, +// so paying for it here would make this path cost two lipgloss renders instead +// of the one it always cost. +func RenderStyle(s string, w int, st lipgloss.Style) string { + return Painter{prefix: StylePrefix(st)}.Fill(s, w) +} diff --git a/internal/ui/layout/text.go b/internal/ui/layout/text.go new file mode 100644 index 0000000..d1d250d --- /dev/null +++ b/internal/ui/layout/text.go @@ -0,0 +1,269 @@ +package layout + +// Width-aware text geometry shared by every view: padding and truncation that +// count terminal cells (not bytes), line wrapping with a hanging indent, and the +// SGR-carry that keeps colour across wrapped rows. All ANSI- and Unicode-aware, +// and free of any Model/Theme dependency, so the fiddly cell math is unit-tested +// in isolation instead of through a rendered view. + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Clamp constrains v to the inclusive range [lo, hi]. +func Clamp(v, lo, hi int) int { + return min(max(v, lo), hi) +} + +// TruncateMiddle keeps both ends of a string visible within n columns. +func TruncateMiddle(s string, n int) string { + if n <= 0 { + return "" + } + plain := ansi.Strip(s) + if lipgloss.Width(plain) <= n { + return plain + } + if n <= 3 { + return TruncateANSI(plain, n) + } + leftW := (n - 1) / 2 + rightW := n - 1 - leftW + totalW := lipgloss.Width(plain) + left := ansi.Truncate(plain, leftW, "") + right := ansi.TruncateLeft(plain, max(0, totalW-rightW), "") + return left + "…" + right +} + +// TruncateANSI naively truncates while keeping the trailing SGR reset. +func TruncateANSI(s string, w int) string { + if w <= 0 { + return "" + } + // ansi.Truncate is width- and escape-aware (and never splits a multi-byte + // rune, unlike a naive byte slice), so it's safe for styled / Unicode text. + return ansi.Truncate(s, w, "") +} + +// FitANSIWidth keeps a styled string intact when it fits within w visible +// columns, and falls back to a plain truncation when it doesn't — so a single +// over-long source line can't break the side-by-side layout while normal-width +// lines retain their syntax colours. +func FitANSIWidth(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) <= w { + return s + } + return TruncateANSI(s, w) +} + +// PadVisual right-pads s to a minimum display width of w columns (ANSI- and +// width-aware), leaving a string that's already wider untouched. This is the +// cell-accurate equivalent of fmt's "%-*s", which counts runes/bytes rather +// than terminal cells and so misaligns columns containing wide or styled text. +func PadVisual(s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} + +// PadRight pads s to exactly w visible columns, truncating when it's longer so +// an over-wide line (e.g. a long demangled symbol) can't wrap and shove the +// layout down behind the status line. +func PadRight(s string, w int) string { + pw := lipgloss.Width(s) + switch { + case pw == w: + return s + case pw > w: + // Truncate (width-aware) and pad any remainder — a wide rune straddling + // the boundary can leave the result a cell short. + s = TruncateANSI(s, w) + if d := w - lipgloss.Width(s); d > 0 { + s += strings.Repeat(" ", d) + } + return s + default: + return s + strings.Repeat(" ", w-pw) + } +} + +// PadBody clamps and pads a rendered body to exactly w by h cells. +func PadBody(s string, w, h int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > h { + lines = lines[:h] + } + // Clamp every line to exactly w columns so nothing wraps and shoves the + // layout (and the status line) down. + for i, l := range lines { + if lipgloss.Width(l) != w { + lines[i] = PadRight(l, w) + } + } + for len(lines) < h { + lines = append(lines, strings.Repeat(" ", w)) + } + return strings.Join(lines, "\n") +} + +// PadBodyRows clamps and pads pre-split rows to exactly w by h cells. +func PadBodyRows(lines []string, w, h int) string { + if len(lines) > h { + lines = lines[:h] + } + for i, l := range lines { + lines[i] = PadRight(l, w) + } + for len(lines) < h { + lines = append(lines, strings.Repeat(" ", w)) + } + return strings.Join(lines, "\n") +} + +// WrapRows splits s into width-limited rows using ansi.Wrap. +func WrapRows(s string, w int, cutset string) []string { + wrapped := ansi.Wrap(s, w, cutset) + rows := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") + if len(rows) == 0 { + return []string{""} + } + return rows +} + +// HardWrapLongRows splits any row still wider than w columns. +func HardWrapLongRows(rows []string, w int) []string { + out := make([]string, 0, len(rows)) + + for _, row := range rows { + if lipgloss.Width(row) <= w { + out = append(out, row) + continue + } + + out = append(out, WrapRows(row, w, "")...) + } + + return out +} + +// IndentContinuationRows applies a hanging indent after the first row. +func IndentContinuationRows(rows []string, w int, indent int) []string { + if len(rows) <= 1 { + return rows + } + + prefix := strings.Repeat(" ", indent) + contW := max(1, w-indent) + + out := make([]string, 0, len(rows)) + out = append(out, rows[0]) + + for _, row := range rows[1:] { + cont := strings.TrimLeft(row, " ") + + for _, part := range WrapRows(cont, contW, " \t/.-_:") { + out = append(out, prefix+part) + } + } + + return out +} + +// RenderLineRowsIndented renders a logical line with optional hanging indent. +func RenderLineRowsIndented(line string, w int, wrap bool, indent int) []string { + if !wrap { + return []string{PadRight(line, w)} + } + + indent = Clamp(indent, 0, max(0, w-1)) + + rows := WrapRows(line, w, " \t/.-_:") + rows = HardWrapLongRows(rows, w) + + if indent > 0 { + rows = IndentContinuationRows(rows, w, indent) + } + + CarryWrapStyle(rows) + + for i := range rows { + rows[i] = PadRight(rows[i], w) + } + return rows +} + +// CarryWrapStyle makes each wrapped row self-contained. A styled span (e.g. a +// coloured symbol or pointer annotation) split across a line break otherwise +// loses its colour: the cell renderer resets the pen at every line, so a +// continuation row that begins mid-span renders with the default colour. This +// re-emits the SGR active at each break — after any leading indent, so the +// hanging indent stays unstyled — and closes every row that ends mid-span. +func CarryWrapStyle(rows []string) { + open := "" + for i, row := range rows { + if open != "" { + j := 0 + for j < len(row) && row[j] == ' ' { + j++ + } + row = row[:j] + open + row[j:] + } + open = LastOpenSGR(open, row) + if open != "" { + row += "\x1b[0m" + } + rows[i] = row + } +} + +// LastOpenSGR returns the SGR sequence still in effect at the end of row, given +// the sequence already open when the row began. A reset ("\x1b[0m" / "\x1b[m") +// clears it; any other SGR replaces it. Styles in this UI are emitted as one +// complete SGR per span (lipgloss does this), so tracking the last sequence is +// sufficient. +func LastOpenSGR(open, row string) string { + for i := 0; i+1 < len(row); i++ { + if row[i] != 0x1b || row[i+1] != '[' { + continue + } + j := i + 2 + for j < len(row) && (row[j] < 0x40 || row[j] > 0x7e) { + j++ + } + if j >= len(row) { + break + } + if row[j] == 'm' { + if seq := row[i : j+1]; seq == "\x1b[0m" || seq == "\x1b[m" { + open = "" + } else { + open = seq + } + } + i = j + } + return open +} + +// AppendRenderedRows appends rendered rows until limit is reached. +func AppendRenderedRows(lines *[]string, line string, w int, wrap bool, limit int) bool { + return AppendRenderedRowsIndented(lines, line, w, wrap, 0, limit) +} + +// AppendRenderedRowsIndented appends indented rendered rows until limit is reached. +func AppendRenderedRowsIndented(lines *[]string, line string, w int, wrap bool, indent int, limit int) bool { + for _, row := range RenderLineRowsIndented(line, w, wrap, indent) { + if len(*lines) >= limit { + return false + } + *lines = append(*lines, row) + } + return len(*lines) < limit +} diff --git a/internal/ui/tree.go b/internal/ui/layout/tree.go similarity index 63% rename from internal/ui/tree.go rename to internal/ui/layout/tree.go index 6d6afc9..96ba23b 100644 --- a/internal/ui/tree.go +++ b/internal/ui/layout/tree.go @@ -1,38 +1,9 @@ -package ui +package layout -import ( - "fmt" - "sort" - "strings" +import "sort" - "github.com/charmbracelet/x/ansi" -) - -// treeNodeRow renders a collapsible group row: indent + arrow + coloured label + -// dim "(count)". Group nodes use the tree-node colour (not the leaf colours); a -// selected node is shown in reverse video of that colour (a coloured cursor cue), -// rather than the full-width white selection bar that leaf rows get. leftPad is -// the view's leading margin ("" for symbols, " " for sources/libs). -func (m *Model) treeNodeRow(depth int, label string, count int, collapsed, selected bool, leftPad string, width int) string { - indent := strings.Repeat(" ", depth*treeIndent) - arrow := "▾ " - if collapsed { - arrow = "▸ " - } - style := m.theme.treeNodeStyle - if selected { - style = style.Reverse(true) - } - cnt := "" - if collapsed { - cnt = fmt.Sprintf(" (%d)", max(count, 0)) // show the hidden-leaf count - } - avail := width - len(leftPad) - len(indent) - 2 - ansi.StringWidth(cnt) - if avail < 1 { - avail = 1 - } - return leftPad + indent + style.Render(arrow+truncateMiddle(label, avail)) + m.theme.srcShadowStyle.Render(cnt) -} +// TreeIndent is the per-depth indentation of a tree row. +const TreeIndent = 2 // A small, reusable collapsible "name tree" shared by the list views (symbols, // sources, libs). It groups path-like strings — C++/Swift scoped names split on @@ -40,25 +11,25 @@ func (m *Model) treeNodeRow(depth int, label string, count int, collapsed, selec // internal nodes can be collapsed. Building and flattening are pure functions; // the owning view keeps the collapse state and the flattened row slice. -// treeNode is one node of a name tree. Internal (group) nodes have leaf == -1 +// TreeNode is one node of a name tree. Internal (group) nodes have leaf == -1 // and children; leaves carry the index of the underlying item (symbol/file/lib). -type treeNode struct { - label string // segment shown for this node; internal nodes keep the trailing separator - path string // internal nodes only: full path from the root, the collapse-state key. Left empty for leaves (never read for them — collapse keys off the item index), which avoids a prefix+label concatenation per leaf (tens of MB on a 100k+-symbol tree). - leaf int // item index for a leaf, -1 for an internal node - count int // number of leaf descendants (for the collapsed "(n)" hint) - children []*treeNode +type TreeNode struct { + Label string // segment shown for this node; internal nodes keep the trailing separator + Path string // internal nodes only: full path from the root, the collapse-state key. Left empty for leaves (never read for them — collapse keys off the item index), which avoids a prefix+label concatenation per leaf (tens of MB on a 100k+-symbol tree). + Leaf int // item index for a leaf, -1 for an internal node + Count int // number of leaf descendants (for the collapsed "(n)" hint) + Children []*TreeNode } -// treeRow is one flattened, currently-visible row: a node and its depth. -type treeRow struct { - node *treeNode - depth int +// TreeRow is one flattened, currently-visible row: a node and its depth. +type TreeRow struct { + Node *TreeNode + Depth int } -// segFunc returns the byte length of the first path segment of s (including its +// SegFunc returns the byte length of the first path segment of s (including its // trailing separator), or -1 when s has no separator (so s is a leaf remainder). -type segFunc func(s string) int +type SegFunc func(s string) int // segScoped splits a scoped name into its first segment by scope/word boundary: // ".", "::" and " " (space), weighed equally — the earliest one at bracket depth @@ -134,8 +105,8 @@ func segUnder(s string) int { return -1 } -// segPath splits on "/" (filesystem paths and library install paths). -func segPath(s string) int { +// SegPath splits on "/" (filesystem paths and library install paths). +func SegPath(s string) int { for i := 0; i < len(s); i++ { if s[i] == '/' { return i + 1 @@ -144,16 +115,16 @@ func segPath(s string) int { return -1 } -// buildTree groups idxs (already sorted by label) into a name tree using seg to +// BuildTree groups idxs (already sorted by label) into a name tree using seg to // pick segment boundaries. -func buildTree(idxs []int, label func(int) string, seg segFunc) []*treeNode { +func BuildTree(idxs []int, label func(int) string, seg SegFunc) []*TreeNode { return buildTreeLevel(idxs, label, seg, 0, "") } -// buildScopedTree groups symbols into a two-pass name tree: first by scope/word +// BuildScopedTree groups symbols into a two-pass name tree: first by scope/word // boundaries (segScoped), then folding whatever that leaves as singletons by a // shared "_" prefix (segUnder). idxs must already be sorted by label. -func buildScopedTree(idxs []int, label func(int) string) []*treeNode { +func BuildScopedTree(idxs []int, label func(int) string) []*TreeNode { return buildScopedLevel(idxs, label, 0, "") } @@ -162,8 +133,8 @@ func buildScopedTree(idxs []int, label func(int) string) []*treeNode { // through to pass 2, which folds them by a shared "_" prefix. Anything still // unique becomes a leaf shown by its remaining path. Groups (from either pass) // sort before leaves, each in label order. -func buildScopedLevel(idxs []int, label func(int) string, prefixLen int, prefix string) []*treeNode { - var nodes []*treeNode +func buildScopedLevel(idxs []int, label func(int) string, prefixLen int, prefix string) []*TreeNode { + var nodes []*TreeNode // Pass 1: fold by scope/word boundary; collect the leftovers (unique segments). var rest []int @@ -205,7 +176,7 @@ func buildScopedLevel(idxs []int, label func(int) string, prefixLen int, prefix continue } } - nodes = append(nodes, &treeNode{label: rem, leaf: rest[i], count: 1}) // leaves need no path + nodes = append(nodes, &TreeNode{Label: rem, Leaf: rest[i], Count: 1}) // leaves need no path i++ } @@ -215,36 +186,36 @@ func buildScopedLevel(idxs []int, label func(int) string, prefixLen int, prefix if a, b := leafRank(nodes[i]), leafRank(nodes[j]); a != b { return a < b } - return nodes[i].label < nodes[j].label + return nodes[i].Label < nodes[j].Label }) return nodes } // sharesSeg reports whether full (at prefixLen) begins with seg and has seg as its // own first segment under fn — i.e. it belongs in the same group. -func sharesSeg(full string, prefixLen, sl int, seg string, fn segFunc) bool { +func sharesSeg(full string, prefixLen, sl int, seg string, fn SegFunc) bool { return len(full) >= prefixLen+sl && full[prefixLen:prefixLen+sl] == seg && fn(full[prefixLen:]) == sl } // scopedGroup builds an internal node for idxs (which all share segment seg), // recursing for its children, compressing single-child chains and summing counts. -func scopedGroup(idxs []int, label func(int) string, seg string, prefixLen int, prefix string) *treeNode { - node := &treeNode{label: seg, path: prefix + seg, leaf: -1} - node.children = buildScopedLevel(idxs, label, prefixLen+len(seg), node.path) +func scopedGroup(idxs []int, label func(int) string, seg string, prefixLen int, prefix string) *TreeNode { + node := &TreeNode{Label: seg, Path: prefix + seg, Leaf: -1} + node.Children = buildScopedLevel(idxs, label, prefixLen+len(seg), node.Path) compressTree(node) - for _, c := range node.children { - node.count += c.count + for _, c := range node.Children { + node.Count += c.Count } return node } -func buildTreeLevel(idxs []int, label func(int) string, seg segFunc, prefixLen int, prefix string) []*treeNode { - var nodes []*treeNode +func buildTreeLevel(idxs []int, label func(int) string, seg SegFunc, prefixLen int, prefix string) []*TreeNode { + var nodes []*TreeNode for i := 0; i < len(idxs); { rem := label(idxs[i])[prefixLen:] sl := seg(rem) if sl < 0 { - nodes = append(nodes, &treeNode{label: rem, leaf: idxs[i], count: 1}) // leaves need no path + nodes = append(nodes, &TreeNode{Label: rem, Leaf: idxs[i], Count: 1}) // leaves need no path i++ continue } @@ -260,15 +231,15 @@ func buildTreeLevel(idxs []int, label func(int) string, seg segFunc, prefixLen i } if j-i == 1 { // A segment owned by a single item needs no group: show it whole as a leaf. - nodes = append(nodes, &treeNode{label: rem, leaf: idxs[i], count: 1}) // leaves need no path + nodes = append(nodes, &TreeNode{Label: rem, Leaf: idxs[i], Count: 1}) // leaves need no path i++ continue } - node := &treeNode{label: segStr, path: prefix + segStr, leaf: -1} - node.children = buildTreeLevel(idxs[i:j], label, seg, prefixLen+sl, node.path) + node := &TreeNode{Label: segStr, Path: prefix + segStr, Leaf: -1} + node.Children = buildTreeLevel(idxs[i:j], label, seg, prefixLen+sl, node.Path) compressTree(node) - for _, c := range node.children { - node.count += c.count + for _, c := range node.Children { + node.Count += c.Count } nodes = append(nodes, node) i = j @@ -280,8 +251,8 @@ func buildTreeLevel(idxs []int, label func(int) string, seg segFunc, prefixLen i return nodes } -func leafRank(n *treeNode) int { - if n.leaf < 0 { +func leafRank(n *TreeNode) int { + if n.Leaf < 0 { return 0 // internal (group) node sorts first } return 1 @@ -289,79 +260,79 @@ func leafRank(n *treeNode) int { // compressTree folds chains of single internal children into one node, so a run // of single-child namespaces (a::b::c::) reads as one row instead of three. -func compressTree(n *treeNode) { - for len(n.children) == 1 && n.children[0].leaf < 0 { - c := n.children[0] - n.label += c.label - n.path = c.path - n.children = c.children +func compressTree(n *TreeNode) { + for len(n.Children) == 1 && n.Children[0].Leaf < 0 { + c := n.Children[0] + n.Label += c.Label + n.Path = c.Path + n.Children = c.Children } } -// flattenTree appends the visible rows of nodes to out: every node, plus the +// FlattenTree appends the visible rows of nodes to out: every node, plus the // children of expanded internal nodes (collapsed[path] hides descendants). -func flattenTree(nodes []*treeNode, collapsed map[string]bool, depth int, out []treeRow) []treeRow { +func FlattenTree(nodes []*TreeNode, collapsed map[string]bool, depth int, out []TreeRow) []TreeRow { for _, n := range nodes { - out = append(out, treeRow{node: n, depth: depth}) - if n.leaf < 0 && !collapsed[n.path] { - out = flattenTree(n.children, collapsed, depth+1, out) + out = append(out, TreeRow{Node: n, Depth: depth}) + if n.Leaf < 0 && !collapsed[n.Path] { + out = FlattenTree(n.Children, collapsed, depth+1, out) } } return out } -// treeExpandOne expands the collapsed node at *cur (one level) and moves the +// TreeExpandOne expands the collapsed node at *cur (one level) and moves the // cursor onto the first item of the now-revealed branch. Returns whether anything // changed (the caller then rebuilds the flattened rows). -func treeExpandOne(rows []treeRow, cur *int, collapsed map[string]bool) bool { +func TreeExpandOne(rows []TreeRow, cur *int, collapsed map[string]bool) bool { if *cur < 0 || *cur >= len(rows) { return false } - n := rows[*cur].node - if n.leaf >= 0 || !collapsed[n.path] { + n := rows[*cur].Node + if n.Leaf >= 0 || !collapsed[n.Path] { return false } - delete(collapsed, n.path) + delete(collapsed, n.Path) *cur++ // land on the first child of the expanded branch return true } -// treeCollapseOne collapses the node at *cur, or — when it is a leaf or already +// TreeCollapseOne collapses the node at *cur, or — when it is a leaf or already // collapsed — the nearest ancestor group above it (moving the cursor onto it). -func treeCollapseOne(rows []treeRow, cur *int, collapsed map[string]bool) bool { +func TreeCollapseOne(rows []TreeRow, cur *int, collapsed map[string]bool) bool { if *cur < 0 || *cur >= len(rows) { return false } row := rows[*cur] - if row.node.leaf < 0 && !collapsed[row.node.path] { - collapsed[row.node.path] = true + if row.Node.Leaf < 0 && !collapsed[row.Node.Path] { + collapsed[row.Node.Path] = true return true } for k := *cur - 1; k >= 0; k-- { - if rows[k].depth < row.depth && rows[k].node.leaf < 0 { + if rows[k].Depth < row.Depth && rows[k].Node.Leaf < 0 { *cur = k - collapsed[rows[k].node.path] = true + collapsed[rows[k].Node.Path] = true return true } } return false } -// treeToggleSubtree expands or collapses the whole subtree under the node at cur: +// TreeToggleSubtree expands or collapses the whole subtree under the node at cur: // collapse-all-below when it is currently expanded, expand-all-below when not. -func treeToggleSubtree(rows []treeRow, cur int, collapsed map[string]bool) bool { - if cur < 0 || cur >= len(rows) || rows[cur].node.leaf >= 0 { +func TreeToggleSubtree(rows []TreeRow, cur int, collapsed map[string]bool) bool { + if cur < 0 || cur >= len(rows) || rows[cur].Node.Leaf >= 0 { return false } - n := rows[cur].node - setSubtreeCollapsed(n, collapsed, !collapsed[n.path]) + n := rows[cur].Node + setSubtreeCollapsed(n, collapsed, !collapsed[n.Path]) return true } // setSubtreeCollapsed collapses (c=true) or expands (c=false) node and every // internal node beneath it in the given collapse set. -func setSubtreeCollapsed(node *treeNode, collapsed map[string]bool, c bool) { - eachInternal([]*treeNode{node}, func(p string) { +func setSubtreeCollapsed(node *TreeNode, collapsed map[string]bool, c bool) { + EachInternal([]*TreeNode{node}, func(p string) { if c { collapsed[p] = true } else { @@ -370,12 +341,12 @@ func setSubtreeCollapsed(node *treeNode, collapsed map[string]bool, c bool) { }) } -// eachInternal calls fn for every internal node's path (used by "collapse all"). -func eachInternal(nodes []*treeNode, fn func(path string)) { +// EachInternal calls fn for every internal node's path (used by "collapse all"). +func EachInternal(nodes []*TreeNode, fn func(path string)) { for _, n := range nodes { - if n.leaf < 0 { - fn(n.path) - eachInternal(n.children, fn) + if n.Leaf < 0 { + fn(n.Path) + EachInternal(n.Children, fn) } } } diff --git a/internal/ui/tree_test.go b/internal/ui/layout/tree_test.go similarity index 92% rename from internal/ui/tree_test.go rename to internal/ui/layout/tree_test.go index 7fc65ae..9b56cf0 100644 --- a/internal/ui/tree_test.go +++ b/internal/ui/layout/tree_test.go @@ -1,4 +1,4 @@ -package ui +package layout import ( "strings" @@ -7,14 +7,14 @@ import ( // dumpTree renders the scoped tree as an indented outline ("[label]" for group // nodes, bare label for leaves) so grouping decisions are easy to assert on. -func dumpTree(nodes []*treeNode, depth int, b *strings.Builder) { +func dumpTree(nodes []*TreeNode, depth int, b *strings.Builder) { for _, n := range nodes { b.WriteString(strings.Repeat(" ", depth)) - if n.leaf < 0 { - b.WriteString("[" + n.label + "]\n") - dumpTree(n.children, depth+1, b) + if n.Leaf < 0 { + b.WriteString("[" + n.Label + "]\n") + dumpTree(n.Children, depth+1, b) } else { - b.WriteString(n.label + "\n") + b.WriteString(n.Label + "\n") } } } @@ -27,7 +27,7 @@ func scopedOutline(names []string) string { idxs[i] = i } var b strings.Builder - dumpTree(buildScopedTree(idxs, func(i int) string { return names[i] }), 0, &b) + dumpTree(BuildScopedTree(idxs, func(i int) string { return names[i] }), 0, &b) return b.String() } diff --git a/internal/ui/wrap_style_test.go b/internal/ui/layout/wrap_test.go similarity index 85% rename from internal/ui/wrap_style_test.go rename to internal/ui/layout/wrap_test.go index bf90e0b..e2e2060 100644 --- a/internal/ui/wrap_style_test.go +++ b/internal/ui/layout/wrap_test.go @@ -1,4 +1,4 @@ -package ui +package layout import ( "strings" @@ -13,7 +13,7 @@ import ( func TestWrappedRowsKeepColour(t *testing.T) { style := lipgloss.NewStyle().Foreground(lipgloss.Color("51")) line := "head " + style.Render("a-long-coloured-token-that-must-wrap-across-several-lines-xyz") - rows := renderLineRowsIndented(line, 24, true, 6) + rows := RenderLineRowsIndented(line, 24, true, 6) if len(rows) < 2 { t.Fatalf("expected the line to wrap, got %d row(s)", len(rows)) } @@ -29,13 +29,13 @@ func TestWrappedRowsKeepColour(t *testing.T) { } func TestLastOpenSGR(t *testing.T) { - if got := lastOpenSGR("", "\x1b[38;5;51mhi"); got != "\x1b[38;5;51m" { + if got := LastOpenSGR("", "\x1b[38;5;51mhi"); got != "\x1b[38;5;51m" { t.Fatalf("open after colour = %q", got) } - if got := lastOpenSGR("\x1b[38;5;51m", "done\x1b[0m"); got != "" { + if got := LastOpenSGR("\x1b[38;5;51m", "done\x1b[0m"); got != "" { t.Fatalf("reset should clear open, got %q", got) } - if got := lastOpenSGR("\x1b[1m", "plain text no codes"); got != "\x1b[1m" { + if got := LastOpenSGR("\x1b[1m", "plain text no codes"); got != "\x1b[1m" { t.Fatalf("carry should persist with no SGR in row, got %q", got) } } diff --git a/internal/ui/libopen.go b/internal/ui/libopen.go new file mode 100644 index 0000000..e69b13b --- /dev/null +++ b/internal/ui/libopen.go @@ -0,0 +1,103 @@ +package ui + +// Shell-side Libs actions: opening a library as the primary file replaces the +// whole model (file navigation), and jumping to a library's imported symbols +// switches modes — both need shell internals, so they live here and the Libs +// view (internal/ui/views/libs) reaches them via view.Host / the key adapter. + +import ( + "fmt" + "os" + "path/filepath" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/dyldcache" + "github.com/rabarbra/exex/internal/explorer" +) + +// openSymbolsForLib shows the Symbols view filtered to the imports bound to lib. +func (m *Model) openSymbolsForLib(lib string) { + n := 0 + for _, s := range m.file.Symbols { + if s.Library == lib { + n++ + } + } + if n == 0 { + m.setStatus("no imported symbols resolved to "+lib, true) + return + } + m.symbols.FilterByLib(m.viewContext(), lib) + m.setMode(modeSymbols) + m.setStatus(fmt.Sprintf("%d symbols imported from %s — Esc clears", n, lib), false) +} + +// openLibAsPrimary opens lib as the primary file, remembering where we came from +// so Back (ctrl+o) returns. It first resolves lib to a standalone file on disk; +// failing that, and when lib is a dyld-shared-cache system library, it extracts +// the dylib straight out of the host's shared cache. +func (m *Model) openLibAsPrimary(lib string) (tea.Model, tea.Cmd) { + if path, ok := explorer.ResolveLibPath(lib, m.file.Path, m.file.Info, nil); ok { + f, err := binfile.Open(path) + if err != nil { + m.setStatus("open library: "+err.Error(), true) + return m, nil + } + return m.enterLibFile(f, filepath.Base(path)) + } + if explorer.IsDyldSharedCacheLib(lib) { + return m.openCacheLib(lib) + } + m.setStatus("could not resolve library on disk: "+lib, true) + return m, nil +} + +// openCacheLib extracts a cache-resident system library out of the host's dyld +// shared cache and opens the stitched image as the primary file. +func (m *Model) openCacheLib(lib string) (tea.Model, tea.Cmd) { + cachePath, ok := dyldcache.HostCachePath(m.file.Arch().String(), func(p string) bool { + _, err := os.Stat(p) + return err == nil + }) + if !ok { + m.setStatus("system library "+lib+" is in the dyld shared cache, which isn't present on this host", true) + return m, nil + } + c, err := dyldcache.Open(cachePath) + if err != nil { + m.setStatus("open dyld cache: "+err.Error(), true) + return m, nil + } + defer c.Close() // the stitched buffer is independent of the mapped cache + im, found := c.FindImage(lib) + if !found { + m.setStatus(lib+" is not in the dyld shared cache image table", true) + return m, nil + } + buf, err := c.ExtractImage(im) + if err != nil { + m.setStatus("extract "+filepath.Base(lib)+" from cache: "+err.Error(), true) + return m, nil + } + f, err := binfile.OpenBytes(im.Path, buf) + if err != nil { + m.setStatus("parse "+filepath.Base(lib)+" from cache: "+err.Error(), true) + return m, nil + } + return m.enterLibFile(f, filepath.Base(lib)+" (cache)") +} + +// enterLibFile builds a model for the opened dependency f and descends into it. +func (m *Model) enterLibFile(f *binfile.File, label string) (tea.Model, tea.Cmd) { + nm, err := New(f, Options{Config: &m.cfg}) + if err != nil { + f.Close() + m.setStatus("open library: "+err.Error(), true) + return m, nil + } + m.enterFile(nm, label) + nm.setStatus("opened dependency "+label+" (Ctrl+O: back)", false) + return nm, tea.Batch(nm.Init(), nm.switchMode(modeInfo)) +} diff --git a/internal/ui/lifecycle.go b/internal/ui/lifecycle.go new file mode 100644 index 0000000..5c71e59 --- /dev/null +++ b/internal/ui/lifecycle.go @@ -0,0 +1,96 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type backgroundState struct { + generation uint64 + inFlight int + closePending bool +} + +type backgroundDoneMsg struct { + owner *Model + generation uint64 + msg tea.Msg +} + +// backgroundCmd tracks physical command completion separately from logical +// running flags, which cancellation clears before worker goroutines have exited. +func (m *Model) backgroundCmd(cmd tea.Cmd) tea.Cmd { + if cmd == nil { + return nil + } + generation := m.background.generation + m.background.inFlight++ + return func() tea.Msg { + return backgroundDoneMsg{owner: m, generation: generation, msg: cmd()} + } +} + +func (m *Model) handleBackgroundDone(msg backgroundDoneMsg) (tea.Model, tea.Cmd) { + owner := msg.owner + if owner == nil { + return m, nil + } + if owner.background.inFlight > 0 { + owner.background.inFlight-- + } + owner.closeRetiredFile() + if owner != m || msg.generation != m.background.generation || msg.msg == nil { + return m, nil + } + return m.Update(msg.msg) +} + +// suspendBackground invalidates model-owned completions and requests prompt +// cancellation. The file stays open because a suspended model may be restored. +func (m *Model) suspendBackground() { + m.background.generation++ + + m.findSeq++ + m.stopFindSearch() + m.findResults.StopScan() + + m.searchSeq++ + m.searchRunning = false + m.searchCancelable = false + m.stopDisasmSearch() + + m.xrefSeq++ + m.xrefRunning = false + m.stopXrefScan() + + m.syscallSeq++ + m.syscallRunning = false + m.stopSyscallScan() + m.syscallFullSeq++ + m.syscallFullRunning = false + m.syscalls.SetFullRunning(false) + m.stopSyscallFullScan() + + m.cpufeatSeq++ + m.cpufeatRunning = false + m.stopCPUFeatScan() + + m.dasm.Decoding = false + m.dasm.PendingAddr = 0 + m.pendingWheel = 0 + m.wheelTicking = false + m.pendingKey = "" + m.pendingKeyN = 0 + m.keyTicking = false +} + +func (m *Model) retireFile() { + m.suspendBackground() + m.background.closePending = true + m.closeRetiredFile() +} + +func (m *Model) closeRetiredFile() { + if !m.background.closePending || m.background.inFlight != 0 || m.file == nil { + return + } + _ = m.file.Close() + m.background.closePending = false +} diff --git a/internal/ui/modal.go b/internal/ui/modal.go new file mode 100644 index 0000000..d130b35 --- /dev/null +++ b/internal/ui/modal.go @@ -0,0 +1,118 @@ +package ui + +// Overlay modal identity. +// +// Exactly one overlay is on top at a time, but which one used to be decided +// independently by the key dispatcher, the renderer, and four separate switches +// in the mouse handler — each a hand-written chain over the same nine +// `xxxActive` booleans, and they disagreed. The renderer put settings third and +// findResults eleventh; the key dispatcher had them the other way around. So a +// state with two flags set would draw one modal while typing into another, and +// nothing enforced that it couldn't happen. +// +// modalOrder below is now the only ordering in the package. activeModal resolves +// it once; render, keys, and mouse all switch on the result, so they cannot +// disagree. The ordering itself is the old key-dispatch chain, which is what +// users' keystrokes already followed. + +type modalKind uint8 + +const ( + modalNone modalKind = iota + modalHeader + modalHelp + modalCPUFeat + modalXref + modalSyscall + modalJump + modalFind + modalFindQuery + modalFindResults + modalSettings + modalGoto + modalSearch +) + +// modalOrder is the single priority ordering over the overlay flags, highest +// first. Adding a modal means adding one row here and one arm to each switch +// that cares — the compiler will not remind you, but the orderings can no longer +// drift apart. +var modalOrder = [...]struct { + kind modalKind + active func(*Model) bool +}{ + {modalHeader, func(m *Model) bool { return m.header.Active() }}, + {modalHelp, func(m *Model) bool { return m.help.Active() }}, + {modalCPUFeat, func(m *Model) bool { return m.cpufeat.Active() }}, + {modalXref, func(m *Model) bool { return m.xref.Active() }}, + {modalSyscall, func(m *Model) bool { return m.syscalls.Active() }}, + {modalJump, func(m *Model) bool { return m.jump.Active() }}, + {modalFind, func(m *Model) bool { return m.find.Active() }}, + {modalFindQuery, func(m *Model) bool { return m.findQueryModal.Active() }}, + {modalFindResults, func(m *Model) bool { return m.findResults.Active() }}, + {modalSettings, func(m *Model) bool { return m.settings.Active() }}, + {modalGoto, func(m *Model) bool { return m.palette.Active() }}, + {modalSearch, func(m *Model) bool { return m.search.Active() }}, +} + +// activeModal returns the overlay currently on top, or modalNone. +func (m *Model) activeModal() modalKind { + for _, e := range modalOrder { + if e.active(m) { + return e.kind + } + } + return modalNone +} + +// renderActiveModal renders the overlay on top, or "" when none is open. Both +// View and the mouse hit-test use it — the hit-test needs the rendered box to +// recover its centred geometry, and rendering it the same way is what keeps the +// clickable rows aligned with the drawn ones. +func (m *Model) renderActiveModal() string { + switch m.activeModal() { + case modalHeader: + return m.header.Render(m.modalContext()) + case modalHelp: + return m.help.Render(m.modalContext()) + case modalCPUFeat: + // Migrated modals record their own list row while rendering; the mouse + // hit-test reads it back through the shell's modalListRow. + out := m.cpufeat.Render(m.modalContext()) + m.modalListRow = m.cpufeat.ListRow() + return out + case modalXref: + out := m.xref.Render(m.modalContext()) + m.modalListRow = m.xref.ListRow() + return out + case modalSyscall: + out := m.syscalls.Render(m.modalContext()) + m.modalListRow = m.syscalls.ListRow() + return out + case modalJump: + out := m.jump.Render(m.modalContext()) + m.modalListRow = m.jump.ListRow() + return out + case modalFind: + out := m.find.Render(m.modalContext()) + m.modalListRow = m.find.ListRow() + return out + case modalFindQuery: + return m.findQueryModal.Render(m.modalContext()) + case modalFindResults: + out := m.findResults.Render(m.modalContext()) + m.modalListRow = m.findResults.ListRow() + return out + case modalSettings: + out := m.settings.Render(m.modalContext(), m) + m.modalListRow = m.settings.ListRow() + return out + case modalGoto: + out := m.palette.Render(m.modalContext(), m) + m.modalListRow = m.palette.ListRow() + return out + case modalSearch: + return m.search.Render(m.modalContext(), m) + } + return "" +} diff --git a/internal/ui/modal/modal.go b/internal/ui/modal/modal.go new file mode 100644 index 0000000..c04f5cd --- /dev/null +++ b/internal/ui/modal/modal.go @@ -0,0 +1,119 @@ +// Package modal is the neutral contract between the exex TUI shell (package ui) +// and the overlay modals, mirroring what package view is for the top-level views. +// A modal depends only on this package (plus layout and binfile), never on ui, so +// modals can live in their own packages without an import cycle. +// +// - Context is a per-frame snapshot of the render inputs a modal needs. Unlike +// view.Context it carries the full terminal Height, because an overlay is +// centred on the screen rather than laid out inside the view body. +// - Host is the small set of mutating actions a modal triggers on the shell. +// +// There is deliberately no Modal interface; see the note further down. +package modal + +import ( + "strings" + + "charm.land/lipgloss/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" +) + +// Context is a per-frame snapshot of the inputs a modal renders from. It is +// passed by value, so the style vocabulary hangs off the embedded *Styles the +// shell builds once per theme change. +type Context struct { + File *binfile.File + Width int // terminal width + Height int // terminal height + *Styles +} + +// Styles is the modal style vocabulary. The shell's full Theme stays private to +// it; a modal that needs another style adds a field here. +type Styles struct { + // Title renders a modal's title bar, Hint its dim footer line, and Frame + // wraps finished content in the modal's border and padding. They are + // functions rather than lipgloss.Style values because the shell composes each + // from more than one style. + Title func(string) string + Frame func(string) string + Hint func(string) string + + SelStyle lipgloss.Style // the selected (cursor) row + InfoStyle lipgloss.Style // positive values / primary list text + WarnStyle lipgloss.Style // warning badges + ErrorStyle lipgloss.Style // error / danger badges + ShadowStyle lipgloss.Style // dim / secondary text + HeadingStyle lipgloss.Style // group headings / symbol names + AddrStyle lipgloss.Style // addresses and offsets + KeyStyle lipgloss.Style // shortcut digits / key badges + AccentStyle lipgloss.Style // row markers and other small accents + RowStyle lipgloss.Style // ordinary list text + DescStyle lipgloss.Style // descriptive text beside a key or label + SwitchStyle lipgloss.Style // the value pill of a clickable toggle + HeadStyle lipgloss.Style // section headers inside a scrollable overlay + + // SymbolStyle colours a symbol row by its kind and binding, exactly as the + // Symbols view does, so a symbol looks the same wherever it is listed. It is a + // function because the theme's colour tables stay private to the shell. + SymbolStyle func(binfile.SymKind, binfile.SymBind) lipgloss.Style +} + +// ListWidth is the content width of a modal's list rows. +func (c Context) ListWidth() int { return layout.Clamp(c.Width-8, 40, 120) } + +// AddrHexWidth is the hex digit count addresses are padded to for this binary. +func (c Context) AddrHexWidth() int { return c.File.AddrHexWidth() } + +// Host is the base set of mutating actions any modal may trigger on the shell. +// +// A modal that needs more declares its own Host embedding this one, rather than +// this interface growing into the union of every modal's needs. The settings +// modal, for instance, needs to read and cycle setting values, which only it +// cares about. +type Host interface { + SetStatus(msg string, isErr bool) + // LoadDisasmAt moves the disassembly view to addr, recording history. + LoadDisasmAt(addr uint64) +} + +// There is deliberately no shared Modal interface, for the same reason package +// view has no View interface: the modals' Render and Update signatures genuinely +// differ (settings must read its values from its own Host; cpufeat needs none), +// so a common interface could only be bought by widening Host into a union type. +// The shell dispatches through the one modalOrder table instead, which is what +// actually keeps render, keys and mouse from disagreeing. Each modal follows the +// same conventional shape: +// +// Active() bool +// Close() +// Render(ctx Context, …) string // records ListRow for the mouse hit-test +// List() (sel *int, top, n int, wrap, ok bool) +// ListRow() int +// ClickRow(listRow int) bool // maps a clicked row to a selection +// Update(…, key string) tea.Cmd +// Activate(…) tea.Cmd // Enter / double-click + +// CenterLine horizontally centres a (possibly styled) line within width w, +// truncating instead when it is already too wide. +func CenterLine(s string, w int) string { + sw := lipgloss.Width(s) + if sw >= w { + return layout.FitANSIWidth(s, w) + } + left := (w - sw) / 2 + return strings.Repeat(" ", left) + s + strings.Repeat(" ", w-sw-left) +} + +// ClickIndex maps a clicked content row to a plain list's selection, reporting +// whether it hit an item. Modals whose rows correspond 1:1 to list items use it; +// settings, whose rows interleave group headers, maps rows itself. +func ClickIndex(sel *int, top, n, listRow int) bool { + if idx := top + listRow; idx >= 0 && idx < n { + *sel = idx + return true + } + return false +} diff --git a/internal/ui/modal_test.go b/internal/ui/modal_test.go new file mode 100644 index 0000000..43fd201 --- /dev/null +++ b/internal/ui/modal_test.go @@ -0,0 +1,147 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + xrefmodal "github.com/rabarbra/exex/internal/ui/modals/xref" +) + +// tabInfoX is an x coordinate inside the "Info" tab of the row-0 tab strip, for +// an 80-column overlayModel. Guarded by TestTabInfoXHitsATab so a tab-strip +// layout change can't quietly make the click assertions vacuous. +const tabInfoX = 12 + +func TestTabInfoXHitsATab(t *testing.T) { + m := overlayModel() + md, ok := m.tabHitTest(tabInfoX) + if !ok { + t.Fatalf("tabHitTest(%d) missed the tab strip; pick a new tabInfoX", tabInfoX) + } + if md == m.mode { + t.Fatalf("tabHitTest(%d) = %v, the model's current mode; the click assertions would be vacuous", tabInfoX, md) + } +} + +func overlayModel() *Model { + m := &Model{ + theme: DefaultTheme(), + file: &binfile.File{}, + mode: modeStrings, + layoutState: layoutState{width: 80, height: 24}, + } + m.strs.List = make([]binfile.StringEntry, 5000) + m.strs.Filter = newPromptInput("", "/ ") + m.strs.Recompute(m.viewContext()) + return m +} + +func click(m *Model, x, y int) { + m.handleMouse(tea.MouseClickMsg(tea.Mouse{Button: tea.MouseLeft, X: x, Y: y})) +} + +func wheel(m *Model, btn tea.MouseButton) { + m.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: btn, X: 10, Y: 5})) +} + +// TestTextOverlaysCaptureMouse pins the fix for events falling through a +// full-screen overlay: handleMouse consulted only a modalActive() helper that +// omitted helpActive and headerActive, so a wheel scrolled — and a click at row +// 0 switched tabs on — the view hidden behind the overlay. +func TestTextOverlaysCaptureMouse(t *testing.T) { + for _, tc := range []struct { + name string + open func(*Model) + }{ + {"help", func(m *Model) { m.help.Open() }}, + {"header", func(m *Model) { m.header.Open() }}, + } { + t.Run(tc.name, func(t *testing.T) { + m := overlayModel() + tc.open(m) + mode, top := m.mode, m.strs.Top + + // Row 0 is the tab strip: a click there must not switch views. + // tabInfoX lands inside a tab, so this would switch modes if the + // event reached the strip. + click(m, tabInfoX, 0) + if m.mode != mode { + t.Errorf("click through overlay switched mode to %v", m.mode) + } + // The wheel must page the overlay, not the view behind it. + wheel(m, tea.MouseWheelDown) + if m.strs.Top != top { + t.Errorf("wheel through overlay scrolled the view (top %d → %d)", top, m.strs.Top) + } + if m.pendingWheel != 0 { + t.Errorf("wheel through overlay queued view scroll (pendingWheel = %d)", m.pendingWheel) + } + }) + } +} + +// TestTextOverlayWheelScrollsOverlay checks the wheel drives the overlay's own +// scroll offset, matching what the arrow keys already did. +func TestTextOverlayWheelScrollsOverlay(t *testing.T) { + m := overlayModel() + m.help.Open() + wheel(m, tea.MouseWheelDown) + if m.help.ScrollOffset() != wheelScrollLines { + t.Errorf("help scroll = %d, want %d", m.help.ScrollOffset(), wheelScrollLines) + } + wheel(m, tea.MouseWheelUp) + if m.help.ScrollOffset() != 0 { + t.Errorf("help scroll = %d after scrolling back, want 0", m.help.ScrollOffset()) + } + + m = overlayModel() + m.header.Open() + wheel(m, tea.MouseWheelDown) + if m.header.ScrollOffset() != wheelScrollLines { + t.Errorf("headerScroll = %d, want %d", m.header.ScrollOffset(), wheelScrollLines) + } +} + +// TestFindQueryPromptCapturesMouse: the free-text search prompt is an overlay +// with no list, so it swallows the mouse rather than letting it reach the view. +func TestFindQueryPromptCapturesMouse(t *testing.T) { + m := overlayModel() + m.findQueryModal.Open() + top := m.strs.Top + + wheel(m, tea.MouseWheelDown) + if m.strs.Top != top || m.pendingWheel != 0 { + t.Errorf("wheel through find-query prompt reached the view (top %d → %d, pending %d)", top, m.strs.Top, m.pendingWheel) + } + click(m, tabInfoX, 0) + if m.mode != modeStrings { + t.Errorf("click through find-query prompt switched mode to %v", m.mode) + } +} + +// TestActiveModalOrdering: render, keys and mouse all resolve the overlay +// through activeModal, so the ordering is defined exactly once. Two flags set at +// once must resolve the same way for every consumer — which is what the old +// per-site chains got wrong (render preferred settings, keys preferred xref). +func TestActiveModalOrdering(t *testing.T) { + m := overlayModel() + m.settings.Open() + m.xref.Open("t", []xrefmodal.Hit{{Addr: 0x1000, Text: "call 0x2000", Sym: "f"}}) + if got := m.activeModal(); got != modalXref { + t.Fatalf("activeModal = %v, want modalXref (higher priority than settings)", got) + } + // The renderer must draw the same modal the keyboard drives. + if got := m.renderActiveModal(); got != m.xref.Render(m.modalContext()) { + t.Error("renderActiveModal drew a different modal than activeModal selected") + } + + m = overlayModel() + if got := m.activeModal(); got != modalNone { + t.Errorf("activeModal with no overlay = %v, want modalNone", got) + } + if got := m.renderActiveModal(); got != "" { + t.Errorf("renderActiveModal with no overlay = %q, want empty", got) + } +} diff --git a/internal/ui/modalcontext.go b/internal/ui/modalcontext.go new file mode 100644 index 0000000..cc4a56b --- /dev/null +++ b/internal/ui/modalcontext.go @@ -0,0 +1,82 @@ +package ui + +// The modal contract lives in internal/ui/modal (modal.Context + modal.Host), so +// a modal can depend on it without importing ui. This file is the shell side: it +// builds a modal.Context from the Model's Theme and geometry, and makes Model +// satisfy modal.Host. +// +// It mirrors viewcontext.go, which does the same for the top-level views. + +import ( + "github.com/rabarbra/exex/internal/ui/modal" + findquerymodal "github.com/rabarbra/exex/internal/ui/modals/findquery" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" + findtomodal "github.com/rabarbra/exex/internal/ui/modals/findto" + jumptomodal "github.com/rabarbra/exex/internal/ui/modals/jumpto" + palettemodal "github.com/rabarbra/exex/internal/ui/modals/palette" + searchmodal "github.com/rabarbra/exex/internal/ui/modals/search" + settingsmodal "github.com/rabarbra/exex/internal/ui/modals/settings" + syscallsmodal "github.com/rabarbra/exex/internal/ui/modals/syscalls" + "github.com/rabarbra/exex/internal/ui/view" +) + +// The shell is the Host for every contract; assert it here so a change to any of +// them fails at this file rather than at a distant call site. A modal that needs +// more than modal.Host declares its own, embedding it (settings.Host). +var ( + _ modal.Host = (*Model)(nil) + _ view.Host = (*Model)(nil) + _ settingsmodal.Host = (*Model)(nil) + _ jumptomodal.Host = (*Model)(nil) + _ findtomodal.Host = (*Model)(nil) + _ palettemodal.Host = (*Model)(nil) + _ findquerymodal.Host = (*Model)(nil) + _ findresultsmodal.Host = (*Model)(nil) + _ searchmodal.Host = (*Model)(nil) + _ syscallsmodal.Host = (*Model)(nil) +) + +// modalContext snapshots the current model state for the open overlay. Unlike +// viewContext it carries the full terminal height, because an overlay is centred +// on the screen rather than laid out inside the view body. +func (m *Model) modalContext() modal.Context { + return modal.Context{ + File: m.file, + Width: m.width, + Height: m.height, + Styles: m.modalStyles(), + } +} + +// modalStyles returns the cached modal style vocabulary, building it on first +// use. Dropped by clearColorCaches on a theme change, like viewStyles. +func (m *Model) modalStyles() *modal.Styles { + if m.modalStylesCache != nil { + return m.modalStylesCache + } + t := m.theme + m.modalStylesCache = &modal.Styles{ + Title: t.modalTitle, + // lipgloss's Render is variadic; the contract wants a plain func(string). + Frame: func(s string) string { return t.modalStyle.Render(s) }, + Hint: t.modalHint, + SelStyle: t.tableSelStyle, + InfoStyle: t.infoStyle, + WarnStyle: t.warnStyle, + ShadowStyle: t.srcShadowStyle, + HeadingStyle: t.symbolNameStyle, + AddrStyle: t.addrStyle, + KeyStyle: t.helpKeyStyle, + AccentStyle: t.headerKey, + RowStyle: t.tableRowStyle, + ErrorStyle: t.errorStyle, + SymbolStyle: t.styleForSymbol, + DescStyle: t.helpDescStyle, + HeadStyle: t.helpHeadStyle, + SwitchStyle: t.switchStyle, + } + return m.modalStylesCache +} + +// Model satisfies modal.Host. SetStatus is already provided for view.Host. +func (m *Model) LoadDisasmAt(addr uint64) { m.loadDisasmAt(addr) } diff --git a/internal/ui/modals/cpufeat/cpufeat.go b/internal/ui/modals/cpufeat/cpufeat.go new file mode 100644 index 0000000..a10cc9e --- /dev/null +++ b/internal/ui/modals/cpufeat/cpufeat.go @@ -0,0 +1,152 @@ +// Package cpufeat is the CPU-features overlay: the set of optional instruction +// families (SSE/AVX/NEON/…) a binary requires, the baseline they imply, and how +// often each is used. Enter jumps to a feature's first use. +// +// The scan that produces the set is analysis (internal/dump) and its async +// orchestration is the shell's, since only the shell owns the event loop. This +// package owns what the scan's result looks like and how it responds to input. +package cpufeat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// visibleRows is how many feature rows fit, leaving room for the modal's title, +// subtitle, footer, border and padding. +func visibleRows(height int) int { return layout.Clamp(height-9, 3, 40) } + +// State is the CPU-features overlay. The zero value is closed and empty. +type State struct { + active bool + scanned bool // a scan has completed; set is the cached result + + set dump.CPUFeatureSet + feats []string // feature names in display order + sel int + top int + + listRow int // content row the list starts on; set by Render +} + +// Scanned reports whether a completed scan is cached, so the shell can reopen +// the overlay without rescanning. +func (s *State) Scanned() bool { return s.scanned } + +// Set returns the cached scan result. +func (s *State) Set() dump.CPUFeatureSet { return s.set } + +// Features returns the feature names in display order. +func (s *State) Features() []string { return s.feats } + +// Open shows the overlay for a scan result and marks it cached. +func (s *State) Open(set dump.CPUFeatureSet) { + s.set = set + s.feats = set.SortedFeatures() + s.sel, s.top = 0, 0 + s.scanned = true + s.active = true +} + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) ListRow() int { return s.listRow } + +// List exposes the feature list to the shell's shared mouse handling. Selection +// does not wrap: this is a flat list, not a settings cycle. +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, s.top, len(s.feats), false, true +} + +// ClickRow selects the feature on a clicked content row. Rows map 1:1 to +// features, so the shared helper does it. +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, s.top, len(s.feats), listRow) +} + +// Update handles one keypress. +func (s *State) Update(ctx modal.Context, host modal.Host, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + case "up", "k": + if s.sel > 0 { + s.sel-- + } + case "down", "j": + if s.sel < len(s.feats)-1 { + s.sel++ + } + case "enter": + return s.Activate(host) + } + return nil +} + +// Activate jumps to the first use of the selected feature and closes. +func (s *State) Activate(host modal.Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.feats) { + return nil + } + addr, ok := s.set.FirstUse[s.feats[s.sel]] + if !ok { + return nil + } + s.Close() + host.LoadDisasmAt(addr) + return nil +} + +func (s *State) Render(ctx modal.Context) string { + var sb strings.Builder + rowW := ctx.ListWidth() + addrW := ctx.AddrHexWidth() + visible := visibleRows(ctx.Height) + + sb.WriteString(ctx.Title("CPU features")) + sb.WriteString("\n") + sub := fmt.Sprintf("%d instructions scanned", s.set.Total) + if s.set.Baseline != "" { + sub = ctx.WarnStyle.Render(s.set.Baseline) + ctx.Hint(" · "+sub) + } else { + sub = ctx.Hint(sub) + } + sb.WriteString(layout.FitANSIWidth(sub, rowW)) + sb.WriteString("\n\n") + s.listRow = 3 + + if len(s.feats) == 0 { + sb.WriteString(" " + ctx.ShadowStyle.Render("only base instructions — no optional CPU features detected") + "\n") + } + nameW := 0 + for _, f := range s.feats { + nameW = max(nameW, len(f)) + } + nameW = layout.Clamp(nameW, 8, 28) + s.top = layout.VisualTop(s.sel, s.top, len(s.feats), visible, func(int) int { return 1 }) + end := min(s.top+visible, len(s.feats)) + for i := s.top; i < end; i++ { + f := s.feats[i] + line := fmt.Sprintf(" %s %s × %s", + ctx.InfoStyle.Render(layout.PadVisual(f, nameW)), + layout.PadVisual(fmt.Sprintf("%d", s.set.Counts[f]), 8), + ctx.ShadowStyle.Render(fmt.Sprintf("first at 0x%0*x", addrW, s.set.FirstUse[f]))) + line = layout.PadRight(line, rowW) + if i == s.sel { + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(ctx.Hint(fmt.Sprintf("↑/↓ select · ↵ jump to first use · Esc close (%d/%d)", + min(s.sel+1, len(s.feats)), len(s.feats)))) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/cpufeat/cpufeat_test.go b/internal/ui/modals/cpufeat/cpufeat_test.go new file mode 100644 index 0000000..aad296a --- /dev/null +++ b/internal/ui/modals/cpufeat/cpufeat_test.go @@ -0,0 +1,133 @@ +package cpufeat + +import ( + "testing" + + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// fakeHost records what the modal asked the shell to do. Before the extraction +// this behaviour could only be exercised through a whole *ui.Model. +type fakeHost struct { + loaded []uint64 + statuses []string +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { h.statuses = append(h.statuses, msg) } +func (h *fakeHost) LoadDisasmAt(addr uint64) { h.loaded = append(h.loaded, addr) } + +func testSet() dump.CPUFeatureSet { + return dump.CPUFeatureSet{ + Total: 100, + Baseline: "x86-64-v3", + Counts: map[string]int{"AVX": 2, "BMI2": 1, "SSE2": 3}, + FirstUse: map[string]uint64{"AVX": 0x1000, "BMI2": 0x2000, "SSE2": 0x3000}, + } +} + +func openState() *State { + s := &State{} + s.Open(testSet()) + return s +} + +func TestOpenSortsFeaturesAndMarksScanned(t *testing.T) { + s := &State{} + if s.Active() || s.Scanned() { + t.Fatal("zero value should be closed and unscanned") + } + s.Open(testSet()) + if !s.Active() || !s.Scanned() { + t.Fatal("Open should open the overlay and mark it scanned") + } + want := []string{"AVX", "BMI2", "SSE2"} // SortedFeatures order + if got := s.Features(); len(got) != len(want) { + t.Fatalf("features = %v, want %v", got, want) + } + // Closing must not discard the cached scan: reopening skips a rescan. + s.Close() + if s.Active() || !s.Scanned() { + t.Errorf("Close dropped the cached scan: active=%v scanned=%v", s.Active(), s.Scanned()) + } +} + +func TestUpdateNavigatesAndCloses(t *testing.T) { + s := openState() + ctx := modal.Context{} + host := &fakeHost{} + + sel, _, n, wrap, ok := s.List() + if !ok || n != 3 || wrap { + t.Fatalf("List() = n=%d wrap=%v ok=%v, want n=3 wrap=false ok=true", n, wrap, ok) + } + + // Up at the top is a no-op, not a wrap. + s.Update(ctx, host, "up") + if *sel != 0 { + t.Errorf("up at top moved selection to %d", *sel) + } + s.Update(ctx, host, "down") + s.Update(ctx, host, "down") + if *sel != 2 { + t.Errorf("selection = %d after two downs, want 2", *sel) + } + // Down at the bottom is a no-op too. + s.Update(ctx, host, "down") + if *sel != 2 { + t.Errorf("down at bottom moved selection to %d", *sel) + } + + s.Update(ctx, host, "esc") + if s.Active() { + t.Error("esc did not close the overlay") + } +} + +func TestActivateJumpsToFirstUseAndCloses(t *testing.T) { + s := openState() + host := &fakeHost{} + + s.Update(modal.Context{}, host, "down") // select BMI2 + if cmd := s.Activate(host); cmd != nil { + t.Error("Activate returned a command; the jump is synchronous") + } + if len(host.loaded) != 1 || host.loaded[0] != 0x2000 { + t.Errorf("jumped to %v, want [0x2000] (BMI2's first use)", host.loaded) + } + if s.Active() { + t.Error("Activate did not close the overlay") + } +} + +// TestActivateOnEmptyIsInert: a scan that found no optional features renders a +// message and has nothing to jump to. +func TestActivateOnEmptyIsInert(t *testing.T) { + s := &State{} + s.Open(dump.CPUFeatureSet{Total: 10}) + host := &fakeHost{} + if cmd := s.Activate(host); cmd != nil { + t.Error("Activate on an empty set returned a command") + } + if len(host.loaded) != 0 { + t.Errorf("Activate on an empty set jumped to %v", host.loaded) + } + if !s.Active() { + t.Error("Activate on an empty set should leave the overlay open") + } +} + +// TestActivateWithoutFirstUseIsInert guards the FirstUse lookup: a feature +// counted but never located must not jump to address zero. +func TestActivateWithoutFirstUseIsInert(t *testing.T) { + s := &State{} + s.Open(dump.CPUFeatureSet{Counts: map[string]int{"AVX": 1}}) // no FirstUse entry + host := &fakeHost{} + s.Activate(host) + if len(host.loaded) != 0 { + t.Errorf("jumped to %v with no FirstUse entry", host.loaded) + } + if !s.Active() { + t.Error("overlay closed despite having nowhere to jump") + } +} diff --git a/internal/ui/modals/findquery/findquery.go b/internal/ui/modals/findquery/findquery.go new file mode 100644 index 0000000..b0da979 --- /dev/null +++ b/internal/ui/modals/findquery/findquery.go @@ -0,0 +1,107 @@ +// Package findquery is the free-text global-search prompt (the `l` key): type a +// symbol name, a string, or a hex address, and it runs the same content scan the +// caret-seeded Find does. +// +// The prompt owns the text and the case-sensitivity toggle. Interpreting the +// text (a 0x… literal is an address; anything else is content) and running the +// scan are the shell's, reached through Host. +package findquery + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Host is what the prompt needs from the shell beyond the modal base. +type Host interface { + modal.Host + // StartTextSearch runs the global search for a typed query. The shell decides + // what the text means and reports when there is nothing searchable in it. + StartTextSearch(text string, caseSensitive bool) tea.Cmd +} + +// State is the prompt overlay. Call SetInput once before use. +type State struct { + active bool + input textinput.Model + caseSensitive bool // toggled with ^i; off by default +} + +// SetInput installs the prompt widget. The shell owns its styling, so it builds +// it and hands it over. +func (s *State) SetInput(in textinput.Model) { s.input = in } + +func (s *State) ensureInput() { + if s.input.Prompt == "" { + s.input = textinput.New() + s.input.Prompt = "search " + } +} + +// Open clears the prompt and focuses it. The case-sensitivity toggle is sticky +// across opens, like a search option rather than a per-query field. +func (s *State) Open() { + s.ensureInput() + s.input.SetValue("") + s.active = true + s.input.Focus() +} + +func (s *State) Close() { + s.active = false + s.input.Blur() +} + +func (s *State) Active() bool { return s.active } + +// Value returns the typed text. +func (s *State) Value() string { return s.input.Value() } + +// CaseSensitive reports whether matching honours case. +func (s *State) CaseSensitive() bool { return s.caseSensitive } + +// Update handles one keypress. Anything that isn't a control types into the +// prompt. +func (s *State) Update(host Host, msg tea.KeyMsg, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + return nil + case "ctrl+i": + s.caseSensitive = !s.caseSensitive + return nil + case "enter": + text := strings.TrimSpace(s.input.Value()) + caseSensitive := s.caseSensitive + s.Close() + return host.StartTextSearch(text, caseSensitive) + } + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return cmd +} + +func (s *State) Render(ctx modal.Context) string { + s.ensureInput() + rowW := ctx.ListWidth() + var sb strings.Builder + sb.WriteString(ctx.Title("Search the binary")) + sb.WriteByte('\n') + sb.WriteByte('\n') + sb.WriteString(" " + s.input.View()) + sb.WriteByte('\n') + sb.WriteByte('\n') + caseTag := ctx.Hint("case-insensitive") + if s.caseSensitive { + caseTag = ctx.WarnStyle.Render("case-sensitive") + } + sb.WriteString(" " + caseTag + ctx.Hint(" (^i)") + "\n") + sb.WriteByte('\n') + sb.WriteString(" " + ctx.Hint("↵ search disasm · data · strings · relocs · Esc cancel")) + return ctx.Frame(layout.PadRight(sb.String(), rowW)) +} diff --git a/internal/ui/modals/findquery/findquery_test.go b/internal/ui/modals/findquery/findquery_test.go new file mode 100644 index 0000000..7bdf83c --- /dev/null +++ b/internal/ui/modals/findquery/findquery_test.go @@ -0,0 +1,152 @@ +package findquery + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/modal" +) + +type started struct { + text string + caseSensitive bool +} + +type fakeHost struct { + searches []started + cmd tea.Cmd +} + +func (h *fakeHost) SetStatus(string, bool) {} +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) StartTextSearch(text string, caseSensitive bool) tea.Cmd { + h.searches = append(h.searches, started{text, caseSensitive}) + return h.cmd +} + +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{ + File: &binfile.File{}, + Width: 100, + Height: 30, + Styles: &modal.Styles{Title: id, Frame: id, Hint: id}, + } +} + +func key() tea.KeyMsg { return tea.KeyMsg(tea.KeyPressMsg{}) } + +func opened() *State { + s := &State{} + in := textinput.New() + in.Prompt = "search " + s.SetInput(in) + s.Open() + return s +} + +func TestOpenClearsAndFocuses(t *testing.T) { + s := opened() + s.input.SetValue("stale") + s.Close() + s.Open() + if !s.Active() || s.Value() != "" { + t.Errorf("Open: active=%v value=%q, want true/empty", s.Active(), s.Value()) + } +} + +func TestEnterStartsTheSearchAndCloses(t *testing.T) { + s := opened() + s.input.SetValue(" malloc ") + host := &fakeHost{} + + s.Update(host, key(), "enter") + if len(host.searches) != 1 { + t.Fatalf("searches = %v, want one", host.searches) + } + // The prompt trims; deciding what the text *means* is the shell's job. + if host.searches[0].text != "malloc" { + t.Errorf("search text = %q, want the trimmed value", host.searches[0].text) + } + if s.Active() { + t.Error("Enter did not close the prompt") + } +} + +// TestEnterOnEmptyStillReachesTheHost: the prompt does not decide that an empty +// query is meaningless — the shell reports "type something to search for", and +// keeping that in one place is why the text is handed over unjudged. +func TestEnterOnEmptyStillReachesTheHost(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "enter") + if len(host.searches) != 1 || host.searches[0].text != "" { + t.Errorf("searches = %v, want one empty query", host.searches) + } +} + +// TestCaseToggleIsStickyAcrossOpens: it is a search option, not a per-query field. +func TestCaseToggleIsStickyAcrossOpens(t *testing.T) { + s := opened() + host := &fakeHost{} + if s.CaseSensitive() { + t.Fatal("case sensitivity should default to off") + } + s.Update(host, key(), "ctrl+i") + if !s.CaseSensitive() { + t.Fatal("^i did not toggle case sensitivity") + } + if !s.Active() { + t.Error("^i closed the prompt") + } + + s.input.SetValue("x") + s.Update(host, key(), "enter") + if !host.searches[0].caseSensitive { + t.Error("the toggle did not reach the search") + } + + s.Open() + if !s.CaseSensitive() { + t.Error("the toggle did not survive a reopen") + } +} + +func TestEscClosesWithoutSearching(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "esc") + if s.Active() { + t.Error("esc did not close the prompt") + } + if len(host.searches) != 0 { + t.Error("esc started a search") + } +} + +func TestEnterPropagatesTheHostCommand(t *testing.T) { + s := opened() + host := &fakeHost{cmd: func() tea.Msg { return nil }} + if got := s.Update(host, key(), "enter"); got == nil { + t.Error("Update dropped the host's command; the search would never run") + } +} + +func TestRenderShowsTheCaseTag(t *testing.T) { + s := opened() + out := s.Render(testCtx()) + if !strings.Contains(out, "Search the binary") { + t.Error("title missing") + } + if !strings.Contains(out, "case-insensitive") { + t.Errorf("default case tag missing:\n%s", out) + } + s.Update(&fakeHost{}, key(), "ctrl+i") + if out = s.Render(testCtx()); !strings.Contains(out, "case-sensitive") { + t.Errorf("toggled case tag missing:\n%s", out) + } +} diff --git a/internal/ui/modals/findresults/findresults.go b/internal/ui/modals/findresults/findresults.go new file mode 100644 index 0000000..07bd762 --- /dev/null +++ b/internal/ui/modals/findresults/findresults.go @@ -0,0 +1,462 @@ +// Package findresults is the global value search's results overlay: every place +// a value occurs across the binary — disasm operands, data words, string +// contents, relocation targets — in one list, tagged by the view it belongs to +// and filterable by that view. +// +// Results stream in: each source scans concurrently and reports when it +// finishes. The overlay therefore owns the streaming *display* state (which +// sources are still running, how many hits arrived), so it can say "searching" +// rather than "no occurrences" while a facet's own scan is still going. Running +// the scans and cancelling them stay in the shell, which owns the event loop. +package findresults + +import ( + "fmt" + "sort" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Facet selects which source's hits are shown; FacetAll shows every source. +type Facet uint8 + +const ( + FacetAll Facet = iota + FacetDisasm + FacetData + FacetStrings + FacetRelocs + FacetCount +) + +func (f Facet) String() string { + switch f { + case FacetDisasm: + return "disasm" + case FacetData: + return "data" + case FacetStrings: + return "strings" + case FacetRelocs: + return "relocs" + default: + return "all" + } +} + +// Hit is one occurrence: which source it came from, its address and/or file +// offset, a context string, and the symbol covering it. +type Hit struct { + Facet Facet + Addr uint64 + Off uint64 + HasAddr bool + Text string + Sym string +} + +// Host is what the overlay needs from the shell beyond the modal base. +type Host interface { + modal.Host + // OpenHit navigates to a hit in the view its facet belongs to. + OpenHit(h Hit) + // CancelSearch abandons any source scans still in flight. + CancelSearch() +} + +// State is the results overlay. Call SetInput once before use. +type State struct { + active bool + label string // the value being searched for, for the title + + hits []Hit + shown []int // indices into hits after facet + text filter + sel int + top int + total int // hits in the active facet before the text filter + + facet Facet + filter textinput.Model + filtering bool + + // Streaming state: which sources are still scanning, so an empty list can say + // "searching …" for the active facet rather than "no occurrences found". + running bool + pending int // source scans still running (0 = done) + facetPending [FacetCount]bool + + listRow int +} + +// SetInput installs the filter widget. The shell owns its styling, so it builds +// it and hands it over. +func (s *State) SetInput(in textinput.Model) { s.filter = in } + +func (s *State) ensureInput() { + if s.filter.Prompt == "" { + s.filter = textinput.New() + s.filter.Prompt = "/ " + } +} + +// Open shows the overlay for a search that is about to start, with `sources` +// scans in flight. Hits arrive later through AddHits. +func (s *State) Open(label string, sources int) { + s.label = label + s.hits = nil + s.shown = nil + s.sel, s.top = 0, 0 + s.facet = FacetAll + s.total = 0 + s.ensureInput() + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false + s.active = true + s.running = true + s.pending = sources + s.facetPending = [FacetCount]bool{} + for f := FacetDisasm; f < FacetCount; f++ { + s.facetPending[f] = sources > 0 + } +} + +// AddHits records one source's finished scan, reporting whether that was the +// last one outstanding. +func (s *State) AddHits(facet Facet, hits []Hit) (finished bool) { + s.hits = append(s.hits, hits...) + if int(facet) < len(s.facetPending) { + s.facetPending[facet] = false + } + s.pending-- + if s.pending <= 0 { + s.running = false + finished = true + } + s.rebuild() + return finished +} + +// StopScan marks the search abandoned, so the overlay stops claiming it is still +// searching. +func (s *State) StopScan() { s.running = false } + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) Running() bool { return s.running } +func (s *State) ListRow() int { return s.listRow } +func (s *State) Sel() int { return s.sel } + +// Hits returns every hit collected so far, across all facets. +func (s *State) Hits() []Hit { return s.hits } + +// Shown returns how many rows the active facet + filter leave visible. +func (s *State) Shown() int { return len(s.shown) } + +// Pending returns how many source scans are still outstanding. +func (s *State) Pending() int { return s.pending } + +// Facet returns the active view facet. +func (s *State) Facet() Facet { return s.facet } + +// SetFacet switches the view facet and rebuilds the visible rows. +func (s *State) SetFacet(f Facet) { + s.facet = f + s.sel, s.top = 0, 0 + s.rebuild() +} + +// Label is the value being searched for. +func (s *State) Label() string { return s.label } + +// Filtering reports whether the filter box has the keyboard. +func (s *State) Filtering() bool { return s.filtering } + +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, s.top, len(s.shown), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, s.top, len(s.shown), listRow) +} + +// rebuild recomputes the displayed indices for the active facet + text filter. +func (s *State) rebuild() { + s.shown = s.shown[:0] + total := 0 + needle := strings.ToLower(strings.TrimSpace(s.filter.Value())) + for i := range s.hits { + h := &s.hits[i] + if s.facet != FacetAll && h.Facet != s.facet { + continue + } + total++ + if needle != "" && !hitMatches(h, needle) { + continue + } + s.shown = append(s.shown, i) + } + s.total = total + // Stable display order — grouped by facet, then address — so streamed-in hits + // don't reshuffle the list as each source reports. + sort.SliceStable(s.shown, func(a, b int) bool { + ha, hb := &s.hits[s.shown[a]], &s.hits[s.shown[b]] + if ha.Facet != hb.Facet { + return ha.Facet < hb.Facet + } + if ha.Addr != hb.Addr { + return ha.Addr < hb.Addr + } + return ha.Off < hb.Off + }) + if s.sel >= len(s.shown) { + s.sel = max(0, len(s.shown)-1) + } +} + +func hitMatches(h *Hit, needle string) bool { + return layout.ContainsFold(h.Text, needle) || layout.ContainsFold(h.Sym, needle) || + layout.ContainsFold(fmt.Sprintf("0x%x", h.Addr), needle) +} + +// facetCounts returns the per-facet hit counts for the facet bar. +func (s *State) facetCounts() [FacetCount]int { + var c [FacetCount]int + for i := range s.hits { + c[s.hits[i].Facet]++ + } + return c +} + +// FacetStillScanning reports whether the active facet's source scan is still +// running (so an empty list should say "searching", not "no occurrences"). +func (s *State) FacetStillScanning() bool { + if s.facet == FacetAll { + return s.running + } + return int(s.facet) < len(s.facetPending) && s.facetPending[s.facet] +} + +// Update drives the results list: Tab cycles the view facet, / filters, Enter +// jumps, Esc closes (cancelling any running scan). +func (s *State) Update(host Host, msg tea.KeyMsg, key string) tea.Cmd { + s.ensureInput() + if s.filtering { + switch key { + case "esc": + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false + s.rebuild() + return nil + case "enter": + return s.Activate(host) + case "up": + s.moveSel(-1) + return nil + case "down": + s.moveSel(1) + return nil + case "tab": + s.filter.Blur() + s.filtering = false + return nil + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.sel, s.top = 0, 0 + s.rebuild() + return cmd + } + + switch key { + case "esc": + s.Close() + host.CancelSearch() + case "tab": + s.facet = (s.facet + 1) % FacetCount + s.sel, s.top = 0, 0 + s.rebuild() + case "shift+tab": + s.facet = (s.facet + FacetCount - 1) % FacetCount + s.sel, s.top = 0, 0 + s.rebuild() + case "/": + s.filtering = true + return s.filter.Focus() + case "up", "k": + s.moveSel(-1) + case "down", "j": + s.moveSel(1) + case "enter", " ": + return s.Activate(host) + } + return nil +} + +func (s *State) moveSel(d int) { + n := len(s.shown) + if n == 0 { + return + } + s.sel = layout.Clamp(s.sel+d, 0, n-1) +} + +// Activate navigates to the selected hit in the view its facet belongs to, and +// cancels any scan still running — the user has found what they wanted. +func (s *State) Activate(host Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.shown) { + return nil + } + h := s.hits[s.shown[s.sel]] + s.Close() + host.CancelSearch() + host.OpenHit(h) + return nil +} + +// facetStyle colours a facet badge/tab by its view. +func facetStyle(ctx modal.Context, f Facet) lipgloss.Style { + switch f { + case FacetDisasm: + return ctx.AccentStyle + case FacetData: + return ctx.WarnStyle + case FacetStrings: + return ctx.InfoStyle + case FacetRelocs: + return ctx.ErrorStyle + default: + return ctx.ShadowStyle + } +} + +// runningNote reports how many of the source scans are still in flight, so the +// overlay shows that results are still streaming in. +func (s *State) runningNote() string { + if s.pending <= 1 { + return "1 source" + } + return fmt.Sprintf("%d sources", s.pending) +} + +// visibleRows is the fixed number of result rows, so the overlay's height is +// constant (no vertical bounce as results stream in or the filter narrows). +func visibleRows(height int) int { return layout.Clamp(height-12, 4, 40) } + +// facetBar renders the view facets as a segmented control with per-facet counts, +// the active one highlighted. +func (s *State) facetBar(ctx modal.Context) string { + counts := s.facetCounts() + var b strings.Builder + for f := Facet(0); f < FacetCount; f++ { + if f > 0 { + b.WriteString(ctx.ShadowStyle.Render(" ")) + } + label := f.String() + if f == FacetAll { + label = fmt.Sprintf("all %d", len(s.hits)) + } else if counts[f] > 0 { + label = fmt.Sprintf("%s %d", f.String(), counts[f]) + } + seg := " " + label + " " + if f == s.facet { + b.WriteString(ctx.SelStyle.Render(seg)) + } else { + b.WriteString(ctx.ShadowStyle.Render(seg)) + } + } + return b.String() +} + +func (s *State) Render(ctx modal.Context) string { + s.ensureInput() + var sb strings.Builder + rowW := ctx.ListWidth() + visible := visibleRows(ctx.Height) + + sb.WriteString(ctx.Title("Find " + s.label)) + if s.running { + // Which sources are still scanning (the disasm decode is the slow one). + sb.WriteString(" " + ctx.WarnStyle.Render("● searching "+s.runningNote())) + } else { + sb.WriteString(" " + ctx.InfoStyle.Render(fmt.Sprintf("✓ %d found", len(s.hits)))) + } + sb.WriteByte('\n') + sb.WriteByte('\n') + sb.WriteString(" " + layout.FitANSIWidth(s.facetBar(ctx), rowW-1)) + sb.WriteByte('\n') + countStr := fmt.Sprintf(" %d", len(s.shown)) + if s.total != len(s.shown) { + countStr = fmt.Sprintf(" %d of %d", len(s.shown), s.total) + } + s.filter.SetWidth(layout.Clamp(rowW-len(countStr)-4, 12, 60)) + sb.WriteString(" " + layout.FitANSIWidth(s.filter.View()+ctx.Hint(countStr), rowW-1)) + sb.WriteByte('\n') + sb.WriteByte('\n') + s.listRow = 5 // title + blank + facet bar + filter + blank + + addrW := ctx.AddrHexWidth() + blank := layout.PadRight("", rowW) + if len(s.shown) == 0 { + // "No occurrences" only once the active facet's own scan has finished — while + // its source is still running (the disasm decode, typically) show "searching". + msg := "no occurrences found" + if s.FacetStillScanning() { + msg = "searching …" + } + for i := range visible { + if i == visible/2 { + sb.WriteString(modal.CenterLine(ctx.Hint(msg), rowW) + "\n") + } else { + sb.WriteString(blank + "\n") + } + } + } else { + s.top = layout.VisualTop(s.sel, s.top, len(s.shown), visible, func(int) int { return 1 }) + const badgeW = 8 + locW := 2 + addrW + ctxW := max(6, rowW-1-badgeW-2-locW-2-18) + for row := range visible { + r := s.top + row + if r >= len(s.shown) { + sb.WriteString(blank + "\n") + continue + } + h := s.hits[s.shown[r]] + badge := facetStyle(ctx, h.Facet).Render(layout.PadVisual(h.Facet.String(), badgeW)) + loc := layout.PadVisual("", locW) + switch { + case h.HasAddr: + loc = ctx.AddrStyle.Render(fmt.Sprintf("0x%0*x", addrW, h.Addr)) + case h.Off != 0 || h.Facet == FacetData: + loc = ctx.ShadowStyle.Render(fmt.Sprintf("@0x%0*x", addrW-1, h.Off)) + } + text := layout.TruncateMiddle(h.Text, ctxW) + sym := "" + if h.Sym != "" { + sym = " " + ctx.ShadowStyle.Render(layout.TruncateMiddle(h.Sym, 16)) + } + line := layout.PadRight(fmt.Sprintf(" %s %s %s%s", badge, loc, text, sym), rowW) + if r == s.sel { + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line + "\n") + } + } + + sb.WriteByte('\n') + hint := "↑/↓ select · ↵ jump · ⇥ view · / filter · Esc cancel" + if s.filtering { + hint = "type to filter · ↵ jump · Tab done · Esc clear" + } + sb.WriteString(" " + ctx.Hint(hint)) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/findresults/findresults_test.go b/internal/ui/modals/findresults/findresults_test.go new file mode 100644 index 0000000..4c166a6 --- /dev/null +++ b/internal/ui/modals/findresults/findresults_test.go @@ -0,0 +1,295 @@ +package findresults + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/modal" +) + +type fakeHost struct { + opened []Hit + cancelled int + statuses []string +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { h.statuses = append(h.statuses, msg) } +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) OpenHit(hit Hit) { h.opened = append(h.opened, hit) } +func (h *fakeHost) CancelSearch() { h.cancelled++ } + +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{ + File: &binfile.File{}, + Width: 100, + Height: 30, + Styles: &modal.Styles{Title: id, Frame: id, Hint: id}, + } +} + +func key() tea.KeyMsg { return tea.KeyMsg(tea.KeyPressMsg{}) } + +func opened() *State { + s := &State{} + in := textinput.New() + in.Prompt = "/ " + s.SetInput(in) + s.Open("_start", 4) + return s +} + +func disasmHit(addr uint64) Hit { + return Hit{Facet: FacetDisasm, Addr: addr, HasAddr: true, Text: "call", Sym: "caller"} +} +func dataHit(off uint64) Hit { + return Hit{Facet: FacetData, Off: off, Text: "pointer word", Sym: ".data"} +} + +func TestOpenStartsRunningWithEverySourcePending(t *testing.T) { + s := opened() + if !s.Active() || !s.Running() || s.Pending() != 4 { + t.Fatalf("Open: active=%v running=%v pending=%d", s.Active(), s.Running(), s.Pending()) + } + if s.Facet() != FacetAll { + t.Errorf("facet = %v, want FacetAll", s.Facet()) + } + // Every source facet is scanning; "all" reports the overall running flag. + for _, f := range []Facet{FacetDisasm, FacetData, FacetStrings, FacetRelocs} { + s.SetFacet(f) + if !s.FacetStillScanning() { + t.Errorf("facet %v should be scanning before any source reports", f) + } + } +} + +// TestAddHitsFinishesOnlyOnTheLastSource: the "✓ N found" state and the final +// status line must wait for every source, not the first one back. +func TestAddHitsFinishesOnlyOnTheLastSource(t *testing.T) { + s := opened() + for i, f := range []Facet{FacetDisasm, FacetData, FacetStrings} { + if finished := s.AddHits(f, nil); finished { + t.Fatalf("source %d (%v) reported the scan finished early", i, f) + } + if !s.Running() { + t.Fatalf("scan stopped running after source %d", i) + } + } + if finished := s.AddHits(FacetRelocs, nil); !finished { + t.Error("the last source did not finish the scan") + } + if s.Running() { + t.Error("scan still running after every source reported") + } +} + +// TestFacetStillScanningIsPerSource: a reported facet stops "searching" even +// while other sources are still running — that distinction is the whole reason +// the overlay tracks per-facet state. +func TestFacetStillScanningIsPerSource(t *testing.T) { + s := opened() + s.AddHits(FacetData, []Hit{dataHit(0x10)}) + + s.SetFacet(FacetData) + if s.FacetStillScanning() { + t.Error("data reported but still marked scanning") + } + s.SetFacet(FacetDisasm) + if !s.FacetStillScanning() { + t.Error("disasm has not reported but is not marked scanning") + } + s.SetFacet(FacetAll) + if !s.FacetStillScanning() { + t.Error("all should report scanning while any source runs") + } +} + +func TestFacetFiltersRowsAndCyclesBothWays(t *testing.T) { + s := opened() + s.AddHits(FacetDisasm, []Hit{disasmHit(0x1000), disasmHit(0x2000)}) + s.AddHits(FacetData, []Hit{dataHit(0x10)}) + + if s.Shown() != 3 { + t.Fatalf("all facet shows %d, want 3", s.Shown()) + } + host := &fakeHost{} + s.Update(host, key(), "tab") // → disasm + if s.Facet() != FacetDisasm || s.Shown() != 2 { + t.Errorf("after tab: facet=%v shown=%d, want disasm/2", s.Facet(), s.Shown()) + } + s.Update(host, key(), "shift+tab") // → back to all + if s.Facet() != FacetAll || s.Shown() != 3 { + t.Errorf("after shift+tab: facet=%v shown=%d, want all/3", s.Facet(), s.Shown()) + } + // shift+tab from "all" wraps to the last facet. + s.Update(host, key(), "shift+tab") + if s.Facet() != FacetRelocs { + t.Errorf("shift+tab from all = %v, want relocs (wrap)", s.Facet()) + } +} + +// TestRowsGroupByFacetThenAddress: hits stream in per source, so the display +// order must not depend on which source reported first. +func TestRowsGroupByFacetThenAddress(t *testing.T) { + s := opened() + s.AddHits(FacetData, []Hit{dataHit(0x20), dataHit(0x10)}) + s.AddHits(FacetDisasm, []Hit{disasmHit(0x2000), disasmHit(0x1000)}) + + var got []Hit + for _, i := range s.shown { + got = append(got, s.hits[i]) + } + if len(got) != 4 { + t.Fatalf("shown %d rows, want 4", len(got)) + } + if got[0].Facet != FacetDisasm || got[1].Facet != FacetDisasm { + t.Errorf("disasm hits are not first: %v", got) + } + if got[0].Addr != 0x1000 || got[1].Addr != 0x2000 { + t.Errorf("disasm hits are not address-ordered: %#x %#x", got[0].Addr, got[1].Addr) + } + if got[2].Off != 0x10 || got[3].Off != 0x20 { + t.Errorf("data hits are not offset-ordered: %#x %#x", got[2].Off, got[3].Off) + } +} + +func TestFilterMatchesTextSymbolAndAddress(t *testing.T) { + for _, tc := range []struct { + needle string + want int + }{ + {"call", 1}, // text + {".data", 1}, // symbol + {"0x1000", 1}, // address + {"nothing", 0}, // no match + } { + t.Run(tc.needle, func(t *testing.T) { + s := opened() + s.AddHits(FacetDisasm, []Hit{disasmHit(0x1000)}) + s.AddHits(FacetData, []Hit{dataHit(0x10)}) + s.filter.SetValue(tc.needle) + s.rebuild() + if s.Shown() != tc.want { + t.Errorf("filter %q shows %d rows, want %d", tc.needle, s.Shown(), tc.want) + } + if s.total != 2 { + t.Errorf("total = %d, want 2 (pre-filter)", s.total) + } + }) + } +} + +// TestActivateCancelsTheScan: the user found what they wanted, so the remaining +// sources should stop rather than keep decoding the image. +func TestActivateCancelsTheScan(t *testing.T) { + s := opened() + s.AddHits(FacetDisasm, []Hit{disasmHit(0x1000)}) + host := &fakeHost{} + + s.Update(host, key(), "enter") + if len(host.opened) != 1 || host.opened[0].Addr != 0x1000 { + t.Errorf("opened = %v, want the selected hit", host.opened) + } + if host.cancelled != 1 { + t.Errorf("cancelled = %d, want 1", host.cancelled) + } + if s.Active() { + t.Error("Enter did not close the overlay") + } +} + +func TestEscCancelsTheScanAndCloses(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "esc") + if s.Active() { + t.Error("esc did not close the overlay") + } + if host.cancelled != 1 { + t.Errorf("esc did not cancel the scan (cancelled=%d)", host.cancelled) + } +} + +// TestEscInFilterOnlyClearsIt mirrors the xref overlay: two meanings for one key. +func TestEscInFilterOnlyClearsIt(t *testing.T) { + s := opened() + s.AddHits(FacetDisasm, []Hit{disasmHit(0x1000)}) + host := &fakeHost{} + + s.Update(host, key(), "/") + if !s.Filtering() { + t.Fatal("/ did not focus the filter") + } + s.filter.SetValue("nothing") + s.rebuild() + s.Update(host, key(), "esc") + if s.Filtering() || !s.Active() { + t.Errorf("esc in filter: filtering=%v active=%v", s.Filtering(), s.Active()) + } + if host.cancelled != 0 { + t.Error("esc in the filter cancelled the scan") + } + if s.Shown() != 1 { + t.Errorf("esc did not clear the filter: shown=%d", s.Shown()) + } +} + +func TestActivateWithNoRowsIsInert(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Activate(host) + if len(host.opened) != 0 || host.cancelled != 0 { + t.Errorf("activate with no rows: opened=%v cancelled=%d", host.opened, host.cancelled) + } +} + +func TestStopScanClearsRunning(t *testing.T) { + s := opened() + s.StopScan() + if s.Running() { + t.Error("StopScan left the overlay claiming to search") + } +} + +// TestRenderSearchingVsFinished: the header and the empty-state message both +// depend on whether the active facet's source has reported. +func TestRenderSearchingVsFinished(t *testing.T) { + s := opened() + out := s.Render(testCtx()) + if !strings.Contains(out, "● searching 4 sources") { + t.Errorf("mid-scan header missing:\n%s", out) + } + if !strings.Contains(out, "searching …") { + t.Error("mid-scan empty state should say searching, not 'no occurrences'") + } + + for _, f := range []Facet{FacetDisasm, FacetData, FacetStrings, FacetRelocs} { + s.AddHits(f, nil) + } + out = s.Render(testCtx()) + if !strings.Contains(out, "✓ 0 found") { + t.Errorf("finished header missing:\n%s", out) + } + if !strings.Contains(out, "no occurrences found") { + t.Error("finished empty state should say no occurrences") + } +} + +func TestRenderShowsFacetBarCounts(t *testing.T) { + s := opened() + s.AddHits(FacetDisasm, []Hit{disasmHit(0x1000), disasmHit(0x2000)}) + s.AddHits(FacetData, []Hit{dataHit(0x10)}) + out := s.Render(testCtx()) + for _, want := range []string{"all 3", "disasm 2", "data 1", "Find _start"} { + if !strings.Contains(out, want) { + t.Errorf("facet bar missing %q:\n%s", want, out) + } + } + if s.ListRow() != 5 { + t.Errorf("ListRow = %d, want 5", s.ListRow()) + } +} diff --git a/internal/ui/modals/findto/findto.go b/internal/ui/modals/findto/findto.go new file mode 100644 index 0000000..8e2216e --- /dev/null +++ b/internal/ui/modals/findto/findto.go @@ -0,0 +1,180 @@ +// Package findto is the "Find from here" seed picker (the `f` key): it lists the +// things at the caret — its address, the pointer it holds, the symbol or section +// covering it, a string, a library path — and on selection launches the global +// value search for that seed. +// +// It is the search counterpart of the jump overlay, which opens the *same* +// position in another view; this searches for the *value* under the caret +// wherever it appears. +// +// Reading the caret is the shell's job (it needs the views and the binary), so +// the shell builds the seeds and this package presents them. +package findto + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" + "github.com/rabarbra/exex/internal/ui/scope" +) + +// Seed is one candidate search: a label, the query value, the scope it searches, +// a human preview, and — for a located seed (symbol/string/section) — the address +// it resolves to, so the global search can look for references to it. +type Seed struct { + Label string + Value string + Scope scope.Scope + Preview string + Addr uint64 + HasAddr bool +} + +// Host is what the picker needs from the shell beyond the modal base. +type Host interface { + modal.Host + // StartSearch launches the global value search for a seed. + StartSearch(seed Seed) tea.Cmd + CopyToClipboard(text, label string) +} + +// State is the seed picker. The zero value is closed. +type State struct { + active bool + sel int + seeds []Seed + listRow int +} + +// Open shows the picker for the seeds found at the caret. It reports whether +// there was anything to show; with no seeds the caller should say so rather than +// opening an empty picker. +func (s *State) Open(seeds []Seed) bool { + if len(seeds) == 0 { + return false + } + s.seeds, s.sel, s.active = seeds, 0, true + return true +} + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) ListRow() int { return s.listRow } + +// Seeds returns the candidates the picker is showing. +func (s *State) Seeds() []Seed { return s.seeds } + +// Sel returns the selected row index. +func (s *State) Sel() int { return s.sel } + +// SetSel selects a seed directly (used by tests and the mouse hit-test). +func (s *State) SetSel(i int) { + if i >= 0 && i < len(s.seeds) { + s.sel = i + } +} + +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, 0, len(s.seeds), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, 0, len(s.seeds), listRow) +} + +// Update drives the picker: up/down move, Enter (or a digit) runs the search for +// the seed, c copies the seed's value, Esc closes. +func (s *State) Update(host Host, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + case "up", "k": + if s.sel > 0 { + s.sel-- + } + case "down", "j": + if s.sel < len(s.seeds)-1 { + s.sel++ + } + case "enter", "space": + return s.Activate(host) + case "c": + // Copy the highlighted seed's value — the symbol name, the address, the + // string, etc. — so the caret's value can be grabbed without searching. + if s.sel >= 0 && s.sel < len(s.seeds) { + seed := s.seeds[s.sel] + host.CopyToClipboard(seed.Value, strings.ToLower(seed.Label)) + } + s.Close() + default: + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { + if i := int(key[0] - '1'); i < len(s.seeds) { + s.sel = i + return s.Activate(host) + } + } + } + return nil +} + +// Activate launches the global value search for the selected candidate. +func (s *State) Activate(host Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.seeds) { + return nil + } + return host.StartSearch(s.seeds[s.sel]) +} + +// seedStyle colours a seed's value by its kind — the address hue for +// addresses/pointers, the symbol hue for symbols, and a readable default for the +// rest — so the value reads as content, not chrome. +func seedStyle(ctx modal.Context, seed Seed) lipgloss.Style { + switch seed.Scope { + case scope.Addr: + return ctx.AddrStyle + case scope.Symbols: + return ctx.HeadingStyle + case scope.Strings: + return ctx.InfoStyle + case scope.Sections: + return ctx.WarnStyle + default: + return ctx.RowStyle + } +} + +func (s *State) Render(ctx modal.Context) string { + var sb strings.Builder + rowW := ctx.ListWidth() + sb.WriteString(ctx.Title("Find")) + sb.WriteByte('\n') + sb.WriteByte('\n') + sb.WriteString(" " + ctx.Hint("search the whole binary for the value under the caret") + "\n") + sb.WriteByte('\n') + s.listRow = 4 // title + blank + subtitle + blank + + const labelW = 9 + prevW := max(4, rowW-4-2-labelW-3) + for i, seed := range s.seeds { + digit := ctx.KeyStyle.Render(fmt.Sprintf("%d", i+1)) + label := ctx.ShadowStyle.Render(layout.PadVisual(seed.Label, labelW)) + where := ctx.ShadowStyle.Render("in " + seed.Scope.String()) + // The value is the point of the row — colour it by kind, never dim. + preview := seedStyle(ctx, seed).Render(layout.TruncateMiddle(seed.Preview, prevW)) + line := fmt.Sprintf(" %s %s %s %s", digit, label, preview, where) + line = layout.PadRight(line, rowW) + if i == s.sel { + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line + "\n") + } + sb.WriteByte('\n') + sb.WriteString(" " + ctx.Hint("↑/↓ select · ↵ or digit search · c copy value · Esc cancel")) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/findto/findto_test.go b/internal/ui/modals/findto/findto_test.go new file mode 100644 index 0000000..f464e55 --- /dev/null +++ b/internal/ui/modals/findto/findto_test.go @@ -0,0 +1,198 @@ +package findto + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/ui/modal" + "github.com/rabarbra/exex/internal/ui/scope" +) + +type fakeHost struct { + searched []Seed + copied [][2]string // {text, label} + statuses []string + cmd tea.Cmd +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { h.statuses = append(h.statuses, msg) } +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) CopyToClipboard(text, label string) { + h.copied = append(h.copied, [2]string{text, label}) +} +func (h *fakeHost) StartSearch(s Seed) tea.Cmd { + h.searched = append(h.searched, s) + return h.cmd +} + +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{Width: 80, Height: 30, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} +} + +func seeds() []Seed { + return []Seed{ + {Label: "Symbol", Value: "_start", Scope: scope.Symbols, Preview: "_start", Addr: 0x401000, HasAddr: true}, + {Label: "Section", Value: ".text", Scope: scope.Sections, Preview: ".text"}, + {Label: "Address", Value: "0x401000", Scope: scope.Addr, Preview: "0x401000"}, + } +} + +func TestOpenWithSeeds(t *testing.T) { + s := &State{} + if !s.Open(seeds()) { + t.Fatal("Open reported no seeds") + } + if !s.Active() || s.Sel() != 0 { + t.Errorf("active=%v sel=%d, want true/0", s.Active(), s.Sel()) + } + if len(s.Seeds()) != 3 { + t.Errorf("Seeds() = %d entries, want 3", len(s.Seeds())) + } +} + +// TestOpenWithNoSeedsStaysClosed: an empty picker is a dead end; the caller says +// "nothing under the caret to search for" instead. +func TestOpenWithNoSeedsStaysClosed(t *testing.T) { + s := &State{} + if s.Open(nil) { + t.Error("Open reported seeds when there were none") + } + if s.Active() { + t.Error("Open activated an empty picker") + } +} + +func TestNavigationClampsAtBothEnds(t *testing.T) { + s := &State{} + s.Open(seeds()) + host := &fakeHost{} + + s.Update(host, "up") // already at the top + if s.Sel() != 0 { + t.Errorf("up at the top moved to %d", s.Sel()) + } + s.Update(host, "down") + s.Update(host, "down") + s.Update(host, "down") // already at the bottom + if s.Sel() != 2 { + t.Errorf("down at the bottom moved to %d, want 2", s.Sel()) + } +} + +func TestEnterStartsSearchForSelectedSeed(t *testing.T) { + s := &State{} + s.Open(seeds()) + host := &fakeHost{} + + s.Update(host, "down") + s.Update(host, "enter") + if len(host.searched) != 1 || host.searched[0].Value != ".text" { + t.Errorf("searched = %v, want the .text seed", host.searched) + } +} + +// TestDigitSelectsAndSearches: 1-9 pick a seed directly; a digit past the end is +// ignored rather than searching the wrong thing. +func TestDigitSelectsAndSearches(t *testing.T) { + s := &State{} + s.Open(seeds()) + host := &fakeHost{} + + s.Update(host, "3") + if len(host.searched) != 1 || host.searched[0].Value != "0x401000" { + t.Errorf("searched = %v, want the address seed", host.searched) + } + if s.Sel() != 2 { + t.Errorf("digit did not move the selection: sel=%d", s.Sel()) + } + + s2 := &State{} + s2.Open(seeds()) + host2 := &fakeHost{} + s2.Update(host2, "9") // only three seeds + if len(host2.searched) != 0 { + t.Errorf("an out-of-range digit searched: %v", host2.searched) + } +} + +// TestCopyCopiesValueNotPreview: the preview is decorated ("→ 0x…", quoted +// strings); the copied text must be the raw value. +func TestCopyCopiesValueNotPreview(t *testing.T) { + s := &State{} + s.Open([]Seed{{Label: "Pointer", Value: "0x402000", Scope: scope.Addr, Preview: "→ 0x402000 msg"}}) + host := &fakeHost{} + + s.Update(host, "c") + if len(host.copied) != 1 { + t.Fatalf("copied = %v, want one entry", host.copied) + } + if host.copied[0][0] != "0x402000" { + t.Errorf("copied text = %q, want the raw value", host.copied[0][0]) + } + if host.copied[0][1] != "pointer" { + t.Errorf("copied label = %q, want the lowercased seed label", host.copied[0][1]) + } + if s.Active() { + t.Error("copy did not close the picker") + } + if len(host.searched) != 0 { + t.Error("copy also started a search") + } +} + +func TestEscClosesWithoutSearching(t *testing.T) { + s := &State{} + s.Open(seeds()) + host := &fakeHost{} + s.Update(host, "esc") + if s.Active() { + t.Error("esc did not close the picker") + } + if len(host.searched) != 0 { + t.Error("esc started a search") + } +} + +func TestActivatePropagatesTheHostCommand(t *testing.T) { + s := &State{} + s.Open(seeds()) + want := tea.Cmd(func() tea.Msg { return nil }) + host := &fakeHost{cmd: want} + if got := s.Activate(host); got == nil { + t.Error("Activate dropped the host's command; the search would never run") + } +} + +func TestClickRow(t *testing.T) { + s := &State{} + s.Open(seeds()) + if !s.ClickRow(2) || s.Sel() != 2 { + t.Errorf("ClickRow(2) did not select row 2 (sel=%d)", s.Sel()) + } + if s.ClickRow(3) { + t.Error("ClickRow past the end reported a hit") + } +} + +// TestRenderShowsSeedsAndScopes: each row names its value and the scope it would +// search, which is what makes the picker self-describing. +func TestRenderShowsSeedsAndScopes(t *testing.T) { + s := &State{} + s.Open(seeds()) + out := s.Render(testCtx()) + if s.ListRow() != 4 { + t.Errorf("ListRow = %d, want 4 (title + blank + subtitle + blank)", s.ListRow()) + } + for _, want := range []string{"Find", "Symbol", "_start", "in symbols", "Section", "in sections", "0x401000", "in address"} { + if !strings.Contains(out, want) { + t.Errorf("rendered picker is missing %q", want) + } + } + // Rows are numbered from 1, matching the digit hotkeys. + if !strings.Contains(out, " 1 ") || !strings.Contains(out, " 3 ") { + t.Errorf("rows are not numbered 1..n:\n%s", out) + } +} diff --git a/internal/ui/modals/help/help.go b/internal/ui/modals/help/help.go new file mode 100644 index 0000000..e4753c6 --- /dev/null +++ b/internal/ui/modals/help/help.go @@ -0,0 +1,230 @@ +// Package help is the keybinding cheat-sheet overlay (the `?` key): a static, +// two-column table of every binding, scrolled when it is taller than the +// terminal and dismissed by any other key. +// +// It has no list and no selection: its scrolling and dismiss-on-any-key +// behaviour is textoverlay.Scroller, shared with the raw-header overlay. +package help + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" + "github.com/rabarbra/exex/internal/ui/modals/textoverlay" +) + +// pageStep is how many rows PgUp/PgDn move the sheet. Its rows are dense +// key/description pairs, so it pages by less than the header overlay. +const pageStep = 8 + +// State is the help overlay. The zero value is closed. +type State struct { + textoverlay.Scroller +} + +// Update handles one keypress: scroll keys page through the sheet, any other key +// dismisses it. +func (s *State) Update(key string) { s.Scroller.Update(key, pageStep) } + +// renderHelpModal lists the keybindings, grouped by scope, in two columns. The +// key column is padded by display width (so multibyte arrows align) and the two +// columns are laid out side by side to keep the modal compact. +// Entry is one line of a help column: a section header, a key/description +// row, or a blank spacer. +type Entry struct { + head string // section title (uppercased + ruled) when non-empty + text string // a pre-rendered key+desc row; "" with no head = blank line +} + +func (s *State) Render(ctx modal.Context) string { + const keyW = 16 + row := func(keys, desc string) Entry { + return Entry{text: ctx.KeyStyle.Render(layout.PadVisual(keys, keyW)) + " " + ctx.DescStyle.Render(desc)} + } + head := func(s string) Entry { return Entry{head: s} } + blank := Entry{} + + // The per-view groups run in view order (the 1–9 · 0 keys), and each group + // lists its keys in the same order as that view's footer, so a key sits in + // the same place whether you read it here or down there. + left := []Entry{ + head("Global"), + row("1–9 · 0", "switch view (0 = relocations)"), + row("g", "goto anything (symbol/section/string/lib/addr · ⇥ scope)"), + row("d/h/m", "go to the caret address in disasm / hex / raw"), + row("␣ · >", "open the caret address in another view (menu)"), + row("f", "find the value under the caret across the binary"), + row("l", "search the binary for anything you type"), + row("/ · n/N", "search or filter this view · next / prev match"), + row("w", "toggle long-line wrap"), + row("⇧h", "raw file header (ELF e_* / Mach-O load cmds / PE)"), + row("⇧f", "CPU features required (SSE/AVX/NEON · baseline)"), + row("^o", "back (return from an opened dependency)"), + row(",", "settings (theme, wrap, …)"), + row("?", "this help"), + row("q · ^c", "quit"), + blank, + head("Every list"), + row("↑/↓ · ↵", "move the cursor · open / jump"), + row("PgUp/PgDn · [ ]", "page ("+layout.CtrlKeys("↑", "↓")+")"), + row("Home/End · ^a/^e", "first / last row"), + row("t", "switch the view's layout (table ↔ tree ↔ …)"), + row("s/r", "sort · reverse"), + row("^…", "the filters named on the status line"), + row("esc", "clear filters"), + row("⇧a/⇧s/⇧l", "copy address / name / whole line"), + blank, + head("Trees"), + row("←/→", "collapse / expand the group"), + row("↵ · +/−", "expand-collapse all below · all"), + blank, + head("Mouse"), + row("click", "select a row · toggle a status-line chip · sort by a column"), + row("double-click", "open / follow, as ↵ does"), + row("wheel", "scroll · over the right pane, scrolls that pane"), + blank, + head("1 · Info"), + row("↵", "open the entry point (or the selected member)"), + row("t", "fat-Mach-O arch slice · static-library members"), + blank, + head("2 · Sections"), + row("↵", "open the section (disasm / hex / raw, as fits)"), + row("t", "sections ↔ segments"), + row(layout.CtrlKeys("t", "f"), "filter by type / flags"), + blank, + head("3 · Symbols"), + row("↵", "jump to the symbol"), + row("e · .", "collapse (…)/<…> to “…” · all rows / this row"), + row("t", "namespace tree ↔ flat table"), + row(layout.CtrlKeys("t", "s", "b"), "filter by type / scope / bind"), + } + right := []Entry{ + head("4 · Disassembly"), + row("↵", "follow the address on this line"), + row("[ ]", "previous / next symbol"), + row("←/→", "history back / forward"), + row("h/m", "this address in hex / raw"), + row("x · y", "find references (xrefs) · list system calls"), + row("a", "disassemble all sections ↔ exec-only (objects, data)"), + row("⇥ · ⇧⇥", "show/hide the source pane · swap the panes"), + row("⇧↑/⇧↓", "scroll the follower pane on its own"), + row("⇧a/⇧s/⇧c", "copy address / symbol / the function's asm"), + row("", "in the xref & syscall lists: / filter · s/r sort"), + blank, + head("5 · Hex · 7 · Raw"), + row("↵", "follow the pointer at the caret"), + row("[ ] · ⇧[ ⇧]", "prev/next section · prev/next nonzero"), + row("d/m · d", "hex → disasm/raw · raw → disasm"), + row("i", "data inspector"), + row("t · ⇧t", "trailing column ascii ↔ numeric · its type"), + row("⇧a/⇧s/⇧p", "copy address / symbol / pointer"), + blank, + head("6 · Libraries"), + row("↵ · o", "its imported symbols · open it as the primary file"), + row("t · r", "path tree ↔ flat list · reverse"), + row(layout.CtrlKeys("p"), "filter: all / on-disk / dyld cache"), + blank, + head("8 · Strings"), + row("↵", "jump to the string in hex"), + row("t", "table ↔ compact (· flow) layout"), + row(layout.CtrlKeys("s", "p"), "filter by section / to paths only"), + blank, + head("9 · Sources"), + row("↵ · o", "open in the disasm view, source-first"), + row("[ ]", "previous / next mapped line"), + row("t", "directory tree ↔ flat list"), + row(layout.CtrlKeys("p"), "filter: all / present / missing"), + blank, + head("0 · Relocations"), + row("↵", "go to the patched address in hex"), + row("e", "collapse (…)/<…> in the bound symbol names"), + row(layout.CtrlKeys("t", "s"), "filter by type / section"), + } + + leftLines := helpColumn(ctx, left) + rightLines := helpColumn(ctx, right) + lw, rw := lipgloss.Width(leftLines[0]), lipgloss.Width(rightLines[0]) + + // Two side-by-side columns when they fit the terminal; otherwise stack into a + // single column so the modal never overruns a narrow window. + var bodyRows []string + if lw+rw+6 <= ctx.Width-6 { + div := ctx.ShadowStyle.Render("│") + n := max(len(leftLines), len(rightLines)) + for i := range n { + l, r := layout.PadVisual("", lw), layout.PadVisual("", rw) + if i < len(leftLines) { + l = leftLines[i] + } + if i < len(rightLines) { + r = rightLines[i] + } + bodyRows = append(bodyRows, l+" "+div+" "+r) + } + } else { + bodyRows = append(bodyRows, leftLines...) + bodyRows = append(bodyRows, layout.PadVisual("", lw)) + bodyRows = append(bodyRows, rightLines...) + } + + // Vertically window the body when it is taller than the screen. + hint := "Mouse: wheel scrolls · over right pane scrolls it · click selects · double-click follows" + total := len(bodyRows) + var from, to int + var scrolled bool + bodyRows, from, to, scrolled = s.Window(bodyRows, ctx.Height) + if scrolled { + hint = fmt.Sprintf("↑/↓ scroll · %d–%d of %d · Esc/any key closes", from, to, total) + } + + // Never let a row push the modal past the terminal (very narrow windows). + rowCap := max(1, ctx.Width-6) + + var b strings.Builder + b.WriteString(ctx.Title("Keybindings")) + b.WriteString("\n\n") + for _, r := range bodyRows { + b.WriteString(layout.FitANSIWidth(r, rowCap)) + b.WriteString("\n") + } + b.WriteString("\n") + // The hint is capped like the body rows. Uncapped it set the overlay's minimum + // width, so on a narrow terminal the footer — not the content — pushed the + // modal past the right edge. + b.WriteString(layout.FitANSIWidth(ctx.Hint(hint), rowCap)) + return ctx.Frame(b.String()) +} + +// helpColumn renders a help column: rows padded to a common width, section +// headers shown uppercase with a dim rule to the column edge (matching the Info +// view), blanks as empty lines. +func helpColumn(ctx modal.Context, entries []Entry) []string { + w := 0 + for _, e := range entries { + if e.head == "" { + if rw := ansi.StringWidth(e.text); rw > w { + w = rw + } + } + } + out := make([]string, len(entries)) + for i, e := range entries { + switch { + case e.head != "": + label := strings.ToUpper(e.head) + " " + line := ctx.HeadStyle.Render(label) + if fill := w - lipgloss.Width(label); fill > 0 { + line += ctx.ShadowStyle.Render(strings.Repeat("─", fill)) + } + out[i] = layout.PadVisual(line, w) + default: + out[i] = layout.PadVisual(e.text, w) + } + } + return out +} diff --git a/internal/ui/modals/help/help_test.go b/internal/ui/modals/help/help_test.go new file mode 100644 index 0000000..f1ee8ea --- /dev/null +++ b/internal/ui/modals/help/help_test.go @@ -0,0 +1,131 @@ +package help + +import ( + "strings" + "testing" + + "github.com/rabarbra/exex/internal/ui/modal" +) + +func testCtx(w, h int) modal.Context { + id := func(s string) string { return s } + return modal.Context{Width: w, Height: h, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} +} + +func TestOpenResetsScroll(t *testing.T) { + s := &State{} + if s.Active() { + t.Fatal("zero value should be closed") + } + s.Scroll(20) + s.Open() + if !s.Active() || s.ScrollOffset() != 0 { + t.Errorf("Open: active=%v scroll=%d, want true/0", s.Active(), s.ScrollOffset()) + } +} + +func TestScrollKeysPageTheSheet(t *testing.T) { + s := &State{} + s.Open() + + s.Update("down") + if s.ScrollOffset() != 1 { + t.Errorf("down = %d, want 1", s.ScrollOffset()) + } + s.Update("pgdown") + if s.ScrollOffset() != 1+pageStep { + t.Errorf("pgdown = %d, want %d", s.ScrollOffset(), 1+pageStep) + } + s.Update("home") + if s.ScrollOffset() != 0 { + t.Errorf("home = %d, want 0", s.ScrollOffset()) + } + // Every scroll key must leave the overlay open, or it would vanish mid-read. + if !s.Active() { + t.Error("a scroll key closed the overlay") + } +} + +// TestAnyOtherKeyDismisses is the documented behaviour of the cheat sheet: it is +// a glance, not a mode. +func TestAnyOtherKeyDismisses(t *testing.T) { + for _, key := range []string{"esc", "q", "?", "x", "enter"} { + s := &State{} + s.Open() + s.Update(key) + if s.Active() { + t.Errorf("%q did not dismiss the overlay", key) + } + } +} + +// TestRenderClampsScroll: Scroll() is unbounded because the row count depends on +// the terminal width, so Render is what has to clamp — including the "end" key's +// deliberate 1<<20 overshoot. +func TestRenderClampsScroll(t *testing.T) { + s := &State{} + s.Open() + s.Update("end") + if s.ScrollOffset() != 1<<20 { + t.Fatalf("end set scroll to %d, want the sentinel overshoot", s.ScrollOffset()) + } + out := s.Render(testCtx(120, 20)) + if s.ScrollOffset() >= 1<<20 { + t.Errorf("Render did not clamp the scroll offset: %d", s.ScrollOffset()) + } + if !strings.Contains(out, "Keybindings") { + t.Error("rendered overlay has no title") + } + + // Scrolling far above the top clamps to zero. + s.Scroll(-1 << 20) + s.Render(testCtx(120, 20)) + if s.ScrollOffset() != 0 { + t.Errorf("scroll above the top = %d, want 0", s.ScrollOffset()) + } +} + +// TestRenderTallWindowShowsEverything: when the sheet fits, there is nothing to +// scroll and the offset is pinned to zero. +func TestRenderTallWindowShowsEverything(t *testing.T) { + s := &State{} + s.Open() + s.Scroll(5) + s.Render(testCtx(200, 200)) + if s.ScrollOffset() != 0 { + t.Errorf("scroll = %d in a window taller than the sheet, want 0", s.ScrollOffset()) + } +} + +// TestRenderNarrowWindowStacksColumns: below the two-column threshold the sheet +// stacks, and must still fit the terminal width. +func TestRenderNarrowWindowStacksColumns(t *testing.T) { + for _, w := range []int{200, 100, 60, 30} { + s := &State{} + s.Open() + out := s.Render(testCtx(w, 40)) + for i, line := range strings.Split(out, "\n") { + if got := lineWidth(line); got > w { + t.Errorf("width %d: line %d is %d columns wide", w, i, got) + break + } + } + } +} + +// lineWidth counts display columns, skipping ANSI escapes. +func lineWidth(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + continue + } + if s[i]&0xc0 != 0x80 { // count only UTF-8 lead bytes + n++ + } + } + return n +} diff --git a/internal/ui/modals/jumpto/jumpto.go b/internal/ui/modals/jumpto/jumpto.go new file mode 100644 index 0000000..d8ad99f --- /dev/null +++ b/internal/ui/modals/jumpto/jumpto.go @@ -0,0 +1,202 @@ +// Package jumpto is the "open caret position in…" overlay: take the address +// under the cursor and offer to reopen it in each of the other views, each row +// previewing exactly where it would land, above a header describing what the +// address *is*. +// +// The overlay knows nothing about views, caret positions, or the binary. The +// shell resolves all of that into a Header and a list of Targets, each carrying +// an opaque ID it hands back through Host.OpenCaretIn. That keeps the two +// concepts the shell owns — what a view is, and where the caret is — out of here. +package jumpto + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Target is one row: a destination the caret can be reopened in, a preview of +// where it would land there, and whether that landing is possible. +type Target struct { + // ID identifies the destination to the shell. The overlay only passes it back. + ID int + // Digit is the view-switch shortcut badge, and doubles as a hotkey. + Digit string + Label string + Preview string + Enabled bool +} + +// Header describes the caret position the overlay was opened for. Every field is +// pre-rendered plain text; the shell resolves symbols, sections and pointers. +type Header struct { + Loc string // "0x1234", or "file 0x10" for an offset-only caret + Context string // symbol · section, or "" + Pointer string // the pointer the slot holds, or "" +} + +// Host is what the overlay needs from the shell beyond the modal base. +type Host interface { + modal.Host + // OpenCaretIn performs the jump for the target with this ID, using the caret + // the overlay was opened for. + OpenCaretIn(id int) +} + +// State is the jump overlay. The zero value is closed. +type State struct { + active bool + sel int + header Header + targets []Target + listRow int +} + +// Open shows the overlay and lands the selection on the first reachable target. +// It reports whether any target is reachable; when none is, the caller should +// leave the overlay closed and say so rather than showing a dead menu. +func (s *State) Open(header Header, targets []Target) (anyEnabled bool) { + s.header, s.targets, s.sel = header, targets, 0 + for i, t := range targets { + if t.Enabled { + if !anyEnabled { + s.sel = i + } + anyEnabled = true + } + } + s.active = anyEnabled + return anyEnabled +} + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) ListRow() int { return s.listRow } + +// Targets returns the rows the overlay is showing. +func (s *State) Targets() []Target { return s.targets } + +// Sel returns the selected row index. +func (s *State) Sel() int { return s.sel } + +// List exposes the target rows to the shell's shared mouse handling. The wheel +// may rest on a disabled row — Activate reports why rather than navigating — +// which is the pre-existing behaviour, and gentler than skipping rows under a +// scroll gesture. +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, 0, len(s.targets), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, 0, len(s.targets), listRow) +} + +// Update drives the selection: up/down move, a target's view digit jumps +// straight to it, Enter opens the selection, Esc closes. +func (s *State) Update(host Host, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + case "up", "k": + s.moveSel(-1) + case "down", "j": + s.moveSel(1) + case "enter", "space": + return s.Activate(host) + default: + if len(key) == 1 && key[0] >= '0' && key[0] <= '9' { + for i, t := range s.targets { + if t.Enabled && t.Digit == key { + s.sel = i + return s.Activate(host) + } + } + } + } + return nil +} + +// moveSel moves the selection by d, skipping disabled rows so the keyboard +// cursor always rests on something actionable. +func (s *State) moveSel(d int) { + n := len(s.targets) + if n == 0 { + return + } + for range s.targets { + s.sel = (s.sel + d + n) % n + if s.targets[s.sel].Enabled { + return + } + } +} + +// Activate performs the selected jump and closes. A disabled row reports its +// reason (carried in Preview) instead of navigating, and leaves the overlay open. +func (s *State) Activate(host Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.targets) { + return nil + } + t := s.targets[s.sel] + if !t.Enabled { + host.SetStatus(t.Label+": "+t.Preview, true) + return nil + } + s.Close() + host.OpenCaretIn(t.ID) + return nil +} + +func (s *State) Render(ctx modal.Context) string { + var sb strings.Builder + rowW := ctx.ListWidth() + + // Header: the address (or file offset), then what it is (symbol · section) + // and, for a data slot, the pointer it holds. Count the lines so the mouse + // hit-test maps rows correctly. + sb.WriteString(ctx.Title("Open ") + " " + ctx.AddrStyle.Render(s.header.Loc)) + sb.WriteString("\n") + headerLines := 1 + if s.header.Context != "" { + sb.WriteString(" " + ctx.HeadingStyle.Render(layout.TruncateANSI(s.header.Context, max(1, rowW-1))) + "\n") + headerLines++ + } + if s.header.Pointer != "" { + sb.WriteString(" " + ctx.ShadowStyle.Render(layout.TruncateANSI(s.header.Pointer, max(1, rowW-1))) + "\n") + headerLines++ + } + sb.WriteString("\n") + headerLines++ + s.listRow = headerLines + + const labelW = 9 + prevW := max(4, rowW-3-2-labelW-3) + faint := lipgloss.NewStyle().Faint(true) + for i, t := range s.targets { + glyph, gStyle := "▸", ctx.AccentStyle + if !t.Enabled { + glyph, gStyle = "·", ctx.ShadowStyle + } + digit := ctx.KeyStyle.Render(t.Digit) + label := layout.PadVisual(t.Label, labelW) + preview := ctx.ShadowStyle.Render(layout.TruncateMiddle(t.Preview, prevW)) + line := fmt.Sprintf(" %s %s %s %s", gStyle.Render(glyph), digit, label, preview) + line = layout.PadRight(line, rowW) + switch { + case i == s.sel: + line = ctx.SelStyle.Render(ansi.Strip(line)) + case !t.Enabled: + line = faint.Render(ansi.Strip(line)) + } + sb.WriteString(line + "\n") + } + sb.WriteString("\n") + sb.WriteString(ctx.Hint("↑/↓ select · ↵ or digit open · Esc cancel")) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/jumpto/jumpto_test.go b/internal/ui/modals/jumpto/jumpto_test.go new file mode 100644 index 0000000..3eaac27 --- /dev/null +++ b/internal/ui/modals/jumpto/jumpto_test.go @@ -0,0 +1,219 @@ +package jumpto + +import ( + "strings" + "testing" + + "github.com/rabarbra/exex/internal/ui/modal" +) + +type fakeHost struct { + opened []int + statuses []string + errs []bool +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { + h.statuses = append(h.statuses, msg) + h.errs = append(h.errs, isErr) +} +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) OpenCaretIn(id int) { h.opened = append(h.opened, id) } + +// testCtx builds a Context whose styles are identity functions, so Render's +// output is plain text and assertions read as what the user sees. +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{Width: 80, Height: 30, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} +} + +func targets() []Target { + return []Target{ + {ID: 5, Digit: "5", Label: "Hex", Preview: "0x1000 .text", Enabled: true}, + {ID: 6, Digit: "6", Label: "Raw", Preview: "offset 0x1000", Enabled: true}, + {ID: 7, Digit: "7", Label: "Strings", Preview: "no string here", Enabled: false}, + } +} + +// disabledFirst puts a disabled row at index 0, so Open must not rest on it. +func disabledFirst() []Target { + return []Target{ + {ID: 1, Digit: "1", Label: "Disasm", Preview: "not executable", Enabled: false}, + {ID: 5, Digit: "5", Label: "Hex", Preview: "0x1000", Enabled: true}, + } +} + +func TestOpenLandsOnFirstEnabledTarget(t *testing.T) { + s := &State{} + if !s.Open(Header{Loc: "0x1000"}, disabledFirst()) { + t.Fatal("Open reported no reachable target") + } + if !s.Active() { + t.Error("Open did not activate the overlay") + } + if s.Sel() != 1 { + t.Errorf("selection = %d, want 1 (the first enabled row)", s.Sel()) + } +} + +// TestOpenWithNoEnabledTargetsStaysClosed: a menu where nothing is reachable is +// worse than a status line saying so. +func TestOpenWithNoEnabledTargetsStaysClosed(t *testing.T) { + s := &State{} + all := []Target{{Label: "Hex", Enabled: false}, {Label: "Raw", Enabled: false}} + if s.Open(Header{}, all) { + t.Error("Open reported a reachable target when none was enabled") + } + if s.Active() { + t.Error("Open activated an overlay with nothing to select") + } +} + +func TestMoveSelSkipsDisabledRows(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) // sel = 0 (Hex) + host := &fakeHost{} + + s.Update(host, "down") // → Raw (1) + if s.Sel() != 1 { + t.Fatalf("after down, sel = %d, want 1", s.Sel()) + } + // Strings (2) is disabled, so the next down wraps past it back to Hex. + s.Update(host, "down") + if s.Sel() != 0 { + t.Errorf("after second down, sel = %d, want 0 (skipped the disabled row)", s.Sel()) + } + s.Update(host, "up") + if s.Sel() != 1 { + t.Errorf("after up, sel = %d, want 1 (skipped the disabled row backwards)", s.Sel()) + } +} + +func TestActivateOpensAndCloses(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) + host := &fakeHost{} + + s.Update(host, "enter") + if len(host.opened) != 1 || host.opened[0] != 5 { + t.Errorf("opened = %v, want [5] (Hex's ID)", host.opened) + } + if s.Active() { + t.Error("Activate did not close the overlay") + } +} + +// TestActivateOnDisabledRowReportsAndStaysOpen: the wheel can rest on a disabled +// row, so Enter there must explain rather than silently do nothing. +func TestActivateOnDisabledRowReportsAndStaysOpen(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) + // Land on the disabled row the way the mouse wheel does, bypassing moveSel. + sel, _, _, _, _ := s.List() + *sel = 2 + + host := &fakeHost{} + s.Activate(host) + if len(host.opened) != 0 { + t.Errorf("a disabled row navigated: %v", host.opened) + } + if len(host.statuses) != 1 || !strings.Contains(host.statuses[0], "no string here") { + t.Errorf("statuses = %v, want the row's reason", host.statuses) + } + if !host.errs[0] { + t.Error("the reason should be reported as an error") + } + if !s.Active() { + t.Error("a disabled row closed the overlay") + } +} + +func TestDigitHotkeyOpensThatTarget(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) + host := &fakeHost{} + + s.Update(host, "6") + if len(host.opened) != 1 || host.opened[0] != 6 { + t.Errorf("opened = %v, want [6] (Raw's ID)", host.opened) + } + + // A digit belonging to a disabled row does nothing. + s2 := &State{} + s2.Open(Header{}, targets()) + host2 := &fakeHost{} + s2.Update(host2, "7") + if len(host2.opened) != 0 { + t.Errorf("a disabled row's digit navigated: %v", host2.opened) + } + if !s2.Active() { + t.Error("a disabled row's digit closed the overlay") + } +} + +func TestEscCloses(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) + s.Update(&fakeHost{}, "esc") + if s.Active() { + t.Error("esc did not close the overlay") + } +} + +func TestClickRowSelectsAnyRowIncludingDisabled(t *testing.T) { + s := &State{} + s.Open(Header{}, targets()) + if !s.ClickRow(2) || s.Sel() != 2 { + t.Errorf("ClickRow(2): hit=%v sel=%d, want true/2", s.ClickRow(2), s.Sel()) + } + if s.ClickRow(99) { + t.Error("ClickRow past the end reported a hit") + } + if s.ClickRow(-1) { + t.Error("ClickRow before the start reported a hit") + } +} + +// TestRenderHeaderLinesDriveListRow: the mouse hit-test maps a click through +// ListRow, so the header's variable height has to be counted exactly. +func TestRenderHeaderLinesDriveListRow(t *testing.T) { + for _, tc := range []struct { + name string + header Header + wantRow int + }{ + {"loc only", Header{Loc: "0x1000"}, 2}, + {"loc + context", Header{Loc: "0x1000", Context: "_start · .text"}, 3}, + {"loc + context + pointer", Header{Loc: "0x1000", Context: "_start · .text", Pointer: "→ 0x402000"}, 4}, + {"loc + pointer", Header{Loc: "0x1000", Pointer: "→ 0x402000"}, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &State{} + s.Open(tc.header, targets()) + out := s.Render(testCtx()) + if s.ListRow() != tc.wantRow { + t.Errorf("ListRow = %d, want %d", s.ListRow(), tc.wantRow) + } + if tc.header.Pointer != "" && !strings.Contains(out, tc.header.Pointer) { + t.Errorf("pointer line missing from the header:\n%s", out) + } + if tc.header.Context != "" && !strings.Contains(out, tc.header.Context) { + t.Errorf("context line missing from the header:\n%s", out) + } + if !strings.Contains(out, tc.header.Loc) { + t.Errorf("location missing from the header:\n%s", out) + } + }) + } +} + +func TestRenderMarksDisabledRows(t *testing.T) { + s := &State{} + s.Open(Header{Loc: "0x1000"}, targets()) + out := s.Render(testCtx()) + for _, want := range []string{"▸", "·", "Hex", "Raw", "Strings", "no string here"} { + if !strings.Contains(out, want) { + t.Errorf("rendered overlay is missing %q", want) + } + } +} diff --git a/internal/ui/modals/palette/palette.go b/internal/ui/modals/palette/palette.go new file mode 100644 index 0000000..1612a62 --- /dev/null +++ b/internal/ui/modals/palette/palette.go @@ -0,0 +1,365 @@ +// Package palette is the "Jump to" command palette (the `g` key): type a query, +// pick a scope, and jump to a symbol, section, string, library or address. +// +// (The package is named for what the overlay is rather than for its key, because +// `goto` is a Go keyword.) +// +// The overlay owns the prompt, the scope selector, the result list and its +// geometry. Searching the binary and routing a chosen result to a view are the +// shell's, reached through Host: the ranked symbol search touches the symbol +// table and the demangled-name index, and "jump to this" means different things +// per kind and per active view. +package palette + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" + "github.com/rabarbra/exex/internal/ui/scope" +) + +// Kind tags a result so it can be coloured and routed to the right view. +type Kind uint8 + +const ( + KindAddr Kind = iota + KindSymbol + KindSection + KindString + KindLib +) + +// ViewLabel names the view a result of this kind opens in, shown as a badge so a +// mixed (All-scope) result list reads as "found in ". +func (k Kind) ViewLabel() string { + switch k { + case KindSymbol: + return "Symbols" + case KindSection: + return "Sections" + case KindString: + return "Strings" + case KindLib: + return "Libs" + default: + return "Address" + } +} + +// Target is one selectable palette entry. +type Target struct { + Kind Kind + Label string + Addr uint64 + Off uint64 // file offset (sections / strings with no virtual address) + Sym binfile.Symbol + HasAddr bool +} + +// Host is what the palette needs from the shell beyond the modal base. +type Host interface { + modal.Host + // Search returns the entries matching query in the given scope. phys asks for + // a typed address to be read as a physical (load) address. + Search(query string, sc scope.Scope, phys bool) []Target + // Activate routes the chosen result to a view. When hasSel is false there were + // no results, and typed is the raw prompt text to fall back on. + Activate(t Target, hasSel bool, typed string) + // HasPhysAddrs reports whether the binary distinguishes load from virtual + // addresses, which is the only case where the ^p toggle means anything. + HasPhysAddrs() bool +} + +// State is the palette overlay. Call SetInput once before use. +type State struct { + active bool + input textinput.Model + scope scope.Scope + addrPhys bool + + results []Target + sel int + top int + listRow int +} + +// SetInput installs the prompt widget. The shell owns its styling, so it builds +// it and hands it over. +func (s *State) SetInput(in textinput.Model) { s.input = in } + +// visibleRows is the constant number of result rows, so the overlay's height +// doesn't bounce up and down as the user types. +func visibleRows(height int) int { return layout.Clamp(height-12, 4, 40) } + +// Open focuses the prompt and runs the initial (empty) search. +func (s *State) Open(host Host) { + s.active = true + s.input.Focus() + s.recompute(host) +} + +// Close dismisses the palette and resets its transient state, so the next open +// starts from a clean prompt in the All scope. +func (s *State) Close() { + s.active = false + s.input.Blur() + s.input.SetValue("") + s.results = s.results[:0] + s.sel, s.top = 0, 0 + s.scope = scope.All + s.addrPhys = false +} + +func (s *State) Active() bool { return s.active } +func (s *State) ListRow() int { return s.listRow } + +// Results returns the entries currently listed. +func (s *State) Results() []Target { return s.results } + +// Sel returns the selected row index. +func (s *State) Sel() int { return s.sel } + +// Scope returns the corpus the palette is searching. +func (s *State) Scope() scope.Scope { return s.scope } + +// Value returns the prompt's text. +func (s *State) Value() string { return s.input.Value() } + +// SetQuery types q into the prompt and re-runs the search. +func (s *State) SetQuery(host Host, q string) { + s.input.SetValue(q) + s.recompute(host) +} + +// Selected returns the highlighted result, or ok=false when there are none. +func (s *State) Selected() (Target, bool) { + if s.sel < 0 || s.sel >= len(s.results) { + return Target{}, false + } + return s.results[s.sel], true +} + +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, s.top, len(s.results), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, s.top, len(s.results), listRow) +} + +// recompute rebuilds the result list from the prompt and scope, resetting the +// selection to the top. +func (s *State) recompute(host Host) { + s.results = host.Search(strings.TrimSpace(s.input.Value()), s.scope, s.addrPhys) + s.sel, s.top = 0, 0 +} + +// Activate jumps to the selection and closes. +func (s *State) Activate(host Host) { + t, ok := s.Selected() + host.Activate(t, ok, strings.TrimSpace(s.input.Value())) + s.Close() +} + +// Update handles one keypress. Anything that isn't a palette control is typed +// into the prompt, which re-runs the search. +func (s *State) Update(host Host, msg tea.KeyMsg, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + return nil + case "up": + if s.sel > 0 { + s.sel-- + } + return nil + case "down": + if s.sel < len(s.results)-1 { + s.sel++ + } + return nil + case "enter": + s.Activate(host) + return nil + case "tab": + s.scope = scope.Next(s.scope) + s.recompute(host) + return nil + case "shift+tab": + s.scope = scope.Prev(s.scope) + s.recompute(host) + return nil + case "ctrl+p": + // Toggle physical-address interpretation (only meaningful when LMA differs). + if host.HasPhysAddrs() { + s.addrPhys = !s.addrPhys + s.recompute(host) + } + return nil + } + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + s.recompute(host) + return cmd +} + +// HandleInput delivers a non-key message (a paste) to the prompt, re-running the +// search only when the text actually changed. +func (s *State) HandleInput(host Host, msg tea.Msg) tea.Cmd { + before := s.input.Value() + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + if s.input.Value() != before { + s.recompute(host) + } + return cmd +} + +// tagStyle colours the kind badge with a distinct hue per kind. In the All scope +// only addr/sym/sec appear, and those three (blue/green/yellow) are clearly +// distinct; str/lib show only in their own scopes. +func tagStyle(ctx modal.Context, k Kind) lipgloss.Style { + switch k { + case KindSymbol: + return ctx.InfoStyle // green + case KindSection: + return ctx.WarnStyle // yellow + case KindString: + return ctx.ErrorStyle // red + case KindLib: + return ctx.ShadowStyle // dim + default: + return ctx.AccentStyle // addr — blue + } +} + +// labelStyle colours a result by kind (symbols by their own kind/bind colour, +// like the Symbols view; other kinds by category). +func labelStyle(ctx modal.Context, t Target) lipgloss.Style { + switch t.Kind { + case KindSymbol: + return ctx.SymbolStyle(t.Sym.Kind, t.Sym.Bind) + case KindSection: + return ctx.InfoStyle + case KindString: + return ctx.RowStyle + case KindLib: + return ctx.HeadingStyle + default: + return ctx.AccentStyle // address + } +} + +// emptyHint names what the current scope searches. +func (s *State) emptyHint() string { + switch s.scope { + case scope.Addr: + return "a hex/decimal address" + case scope.Strings: + return "a printable string" + case scope.Libs: + return "a linked library" + case scope.Sections: + return "a section name" + case scope.Symbols: + return "a symbol name" + default: + return "a symbol, section or address" + } +} + +// scopeBar renders the scope selector with the active scope highlighted, plus the +// physical-address toggle when the binary has distinct LMAs. +func (s *State) scopeBar(ctx modal.Context, host Host) string { + var b strings.Builder + for sc := scope.Scope(0); sc < scope.Count; sc++ { + if sc > 0 { + b.WriteString(ctx.ShadowStyle.Render(" ")) + } + if sc == s.scope { + b.WriteString(ctx.SelStyle.Render(" " + sc.String() + " ")) + } else { + b.WriteString(ctx.ShadowStyle.Render(" " + sc.String() + " ")) + } + } + if (s.scope == scope.All || s.scope == scope.Addr) && host.HasPhysAddrs() { + tag := "virtual" + if s.addrPhys { + tag = ctx.WarnStyle.Render("physical") + } + b.WriteString(ctx.Hint(" addr: ") + tag + ctx.Hint(" (^p)")) + } + return b.String() +} + +func (s *State) Render(ctx modal.Context, host Host) string { + var sb strings.Builder + rowW := ctx.ListWidth() + visible := visibleRows(ctx.Height) + + // Header: title, a blank line for breathing room, the scope tabs, and the input + // — each indented one column to line up with the result rows below. + sb.WriteString(ctx.Title("Jump to")) + sb.WriteByte('\n') + sb.WriteByte('\n') + sb.WriteString(" " + layout.FitANSIWidth(s.scopeBar(ctx, host), rowW-1)) + sb.WriteByte('\n') + sb.WriteString(" " + s.input.View()) + sb.WriteByte('\n') + sb.WriteByte('\n') + s.listRow = 5 // title + blank + scope bar + input + blank → list at row 5 + + // Body: exactly `visible` lines, always — result rows padded out with blanks (or + // a centred empty hint), so the modal keeps a constant height. + blank := layout.PadRight("", rowW) + if len(s.results) == 0 { + for i := range visible { + if i == visible/2 { + hint := ctx.Hint("type to search — " + s.emptyHint()) + sb.WriteString(modal.CenterLine(hint, rowW) + "\n") + } else { + sb.WriteString(blank + "\n") + } + } + } else { + addrW := ctx.AddrHexWidth() + s.top = layout.VisualTop(s.sel, s.top, len(s.results), visible, func(int) int { return 1 }) + const badgeW = 9 + labelW := max(4, rowW-badgeW-3-addrW-3) + for row := range visible { + i := s.top + row + if i >= len(s.results) { + sb.WriteString(blank + "\n") + continue + } + t := s.results[i] + loc := strings.Repeat(" ", 2+addrW) + if t.HasAddr || t.Kind == KindAddr { + loc = ctx.AddrStyle.Render(fmt.Sprintf("0x%0*x", addrW, t.Addr)) + } + badge := tagStyle(ctx, t.Kind).Render(layout.PadVisual(t.Kind.ViewLabel(), badgeW)) + label := labelStyle(ctx, t).Render(layout.TruncateMiddle(t.Label, labelW)) + line := layout.PadRight(fmt.Sprintf(" %s %s %s", badge, loc, label), rowW) + if i == s.sel { + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line + "\n") + } + } + + count := "" + if n := len(s.results); n > 0 { + count = fmt.Sprintf(" (%d/%d)", s.sel+1, n) + } + sb.WriteByte('\n') + sb.WriteString(" " + ctx.Hint("↑/↓ select · ↵ jump · ⇥ scope · Esc cancel"+count)) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/rawheader/rawheader.go b/internal/ui/modals/rawheader/rawheader.go new file mode 100644 index 0000000..f29faf3 --- /dev/null +++ b/internal/ui/modals/rawheader/rawheader.go @@ -0,0 +1,64 @@ +// Package rawheader is the raw container-header overlay (⇧H): the ELF e_* +// fields, the Mach-O mach_header and load commands, or the PE COFF/optional +// header, as an aligned field table. +// +// The header is a property of the whole file, so it is an overlay rather than a +// Sections sub-mode. Like the help sheet it has no list and no selection, and +// shares textoverlay.Scroller for its paging and dismiss-on-any-key behaviour. +package rawheader + +import ( + "fmt" + "strings" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" + "github.com/rabarbra/exex/internal/ui/modals/textoverlay" +) + +// fieldKeyWidth is the aligned width of the field-name column. +const fieldKeyWidth = 20 + +// pageStep is how many rows PgUp/PgDn move. Header rows are one field each, so +// it pages by more than the denser help sheet. +const pageStep = 10 + +// State is the raw-header overlay. The zero value is closed. +type State struct { + textoverlay.Scroller +} + +// Update handles one keypress: scroll keys page through the fields, any other +// key dismisses the overlay. +func (s *State) Update(key string) { s.Scroller.Update(key, pageStep) } + +// Render draws the field table for the binary in ctx. +func (s *State) Render(ctx modal.Context) string { + fields := ctx.File.RawHeader() + rowW := ctx.ListWidth() + var sb strings.Builder + sb.WriteString(ctx.Title(string(ctx.File.Format) + " header")) + sb.WriteString("\n\n") + if len(fields) == 0 { + sb.WriteString(" " + ctx.ShadowStyle.Render("no raw header fields for this format") + "\n") + return ctx.Frame(sb.String()) + } + + // Build every row, then window vertically to the terminal height. + rows := make([]string, 0, len(fields)) + for _, f := range fields { + row := " " + ctx.AccentStyle.Render(layout.PadVisual(f.Name, fieldKeyWidth)) + " " + + ctx.RowStyle.Render(f.Value) + rows = append(rows, layout.FitANSIWidth(row, rowW)) + } + total := len(rows) + rows, from, to, scrolled := s.Window(rows, ctx.Height) + hint := "↑/↓ scroll · Esc/⇧H close" + if scrolled { + hint = fmt.Sprintf("↑/↓ scroll · %d–%d of %d · Esc closes", from, to, total) + } + sb.WriteString(strings.Join(rows, "\n")) + sb.WriteString("\n\n") + sb.WriteString(ctx.Hint(hint)) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/rawheader/rawheader_test.go b/internal/ui/modals/rawheader/rawheader_test.go new file mode 100644 index 0000000..dea6416 --- /dev/null +++ b/internal/ui/modals/rawheader/rawheader_test.go @@ -0,0 +1,98 @@ +package rawheader + +import ( + "strings" + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/testbin" + "github.com/rabarbra/exex/internal/ui/modal" +) + +func ctxFor(t *testing.T, w, h int) modal.Context { + t.Helper() + f, err := binfile.Open(testbin.WriteTinyELF64(t)) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { f.Close() }) + id := func(s string) string { return s } + return modal.Context{File: f, Width: w, Height: h, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} +} + +func TestRenderShowsHeaderFields(t *testing.T) { + var s State + s.Open() + out := s.Render(ctxFor(t, 100, 40)) + if !strings.Contains(out, "ELF header") { + t.Errorf("title missing:\n%s", out) + } + // The ELF fixture's header fields, as the table labels them. + for _, want := range []string{"Type", "ET_EXEC", "Machine", "EM_X86_64", "Entry", "0x401000"} { + if !strings.Contains(out, want) { + t.Errorf("field %q missing from the table", want) + } + } +} + +// TestRenderScrollsWhenTallerThanTheTerminal: the footer switches from the +// static hint to a range indicator only when there is something to scroll. +func TestRenderScrollsWhenTallerThanTheTerminal(t *testing.T) { + var s State + s.Open() + + tall := s.Render(ctxFor(t, 100, 200)) + if !strings.Contains(tall, "Esc/⇧H close") { + t.Errorf("a fitting table should show the static hint:\n%s", tall) + } + if s.ScrollOffset() != 0 { + t.Errorf("scroll = %d in a tall window", s.ScrollOffset()) + } + + short := s.Render(ctxFor(t, 100, 14)) + if !strings.Contains(short, " of ") || !strings.Contains(short, "Esc closes") { + t.Errorf("a scrolled table should show the range:\n%s", short) + } +} + +// TestEndKeyClampsToTheBottom exercises the sentinel Scroller.Update sets, which +// only Render can resolve. +func TestEndKeyClampsToTheBottom(t *testing.T) { + var s State + s.Open() + s.Update("end") + s.Render(ctxFor(t, 100, 14)) + if got := s.ScrollOffset(); got == 0 || got >= 1<<20 { + t.Errorf("end clamped to %d, want a real bottom offset", got) + } + if !s.Active() { + t.Error("end closed the overlay") + } +} + +func TestAnyOtherKeyDismisses(t *testing.T) { + var s State + s.Open() + s.Update("esc") + if s.Active() { + t.Error("esc did not dismiss the overlay") + } +} + +// TestRenderEmptyHeader: a format with no raw header fields shows a message +// rather than an empty box. +func TestRenderEmptyHeader(t *testing.T) { + id := func(s string) string { return s } + ctx := modal.Context{ + File: &binfile.File{}, + Width: 100, + Height: 30, + Styles: &modal.Styles{Title: id, Frame: id, Hint: id}, + } + var s State + s.Open() + out := s.Render(ctx) + if !strings.Contains(out, "no raw header fields") { + t.Errorf("empty header did not explain itself:\n%s", out) + } +} diff --git a/internal/ui/modals/search/search.go b/internal/ui/modals/search/search.go new file mode 100644 index 0000000..1499b97 --- /dev/null +++ b/internal/ui/modals/search/search.go @@ -0,0 +1,243 @@ +// Package search is the in-view search prompt (the `/` key): a query box above +// a strip of clickable toggles for the match mode, case sensitivity, direction +// and origin. +// +// The prompt owns the query text and those four options. Running the search — +// which means different things in the hex, disassembly and source views — is the +// shell's, reached through Host. +package search + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/rabarbra/exex/internal/bytesearch" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Options is how the prompt is configured to match: the four toggles. +type Options struct { + Mode bytesearch.Mode + CaseSensitive bool + Forward bool + FromCursor bool +} + +// Host is what the prompt needs from the shell beyond the modal base. +type Host interface { + modal.Host + // SearchHint describes what the active view searches ("bytes", "instruction + // text", …), shown under the title. + SearchHint() string + // SubmitSearch runs the query in the active view. + SubmitSearch(query string, o Options) tea.Cmd + // SearchCaseChanged tells the shell that case sensitivity flipped, so caches + // whose hits were computed under the old setting must be dropped. + SearchCaseChanged() +} + +// Switch is one clickable toggle: a dim name and the current value, rendered as +// a pill ("name ⟦value⟧"). +type Switch struct { + Name string + Value string +} + +// Label is the plain "name ⟦value⟧" text. Its width drives both the render and +// the mouse hit-test, so the two cannot drift. +func (s Switch) Label() string { return s.Name + " ⟦" + s.Value + "⟧" } + +const ( + // SwitchSep separates the switch segments. Exported so the shell's mouse + // geometry test can rebuild the strip layout instead of hard-coding it. + SwitchSep = " " + // SwitchLine is the 0-based content row the switch strip occupies inside the + // overlay (title, hint, blank, input, blank, switches). + SwitchLine = 5 + // switchIndent is the strip's leading column, matched by Render and ClickAt. + switchIndent = 1 +) + +// State is the prompt overlay. Call Init once before use. +type State struct { + active bool + input textinput.Model + opts Options +} + +// Init installs the prompt widget and the default options: forward, from the +// cursor, case-insensitive, auto mode. They are not the zero values, so a State +// must be initialised rather than merely declared. +func (s *State) Init(in textinput.Model) { + s.input = in + s.opts = Options{Mode: bytesearch.ModeAuto, Forward: true, FromCursor: true} +} + +func (s *State) ensureInput() { + if s.input.Prompt == "" { + s.input = textinput.New() + s.input.Prompt = "/ " + } +} + +// Open clears the query and focuses the prompt. Repeat search (n/N) still uses +// the last query, but each new prompt starts empty so stale input is not +// accidentally reused. +func (s *State) Open() { + s.ensureInput() + s.active = true + s.input.SetValue("") + s.input.Focus() +} + +func (s *State) Close() { + s.active = false + s.input.Blur() +} + +func (s *State) Active() bool { return s.active } + +// HandleInput delivers a non-key message (a paste) to the query box. +func (s *State) HandleInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return cmd +} + +// Value returns the typed query. +func (s *State) Value() string { return s.input.Value() } + +// SetOptions overrides the toggles (tests, and restoring a saved configuration). +func (s *State) SetOptions(o Options) { s.opts = o } + +// Options returns the current match configuration, which the shell also consults +// when repeating a search with n/N. +func (s *State) Options() Options { return s.opts } + +// Mode, CaseSensitive, Forward and FromCursor are the individual toggles. +func (s *State) Mode() bytesearch.Mode { return s.opts.Mode } +func (s *State) CaseSensitive() bool { return s.opts.CaseSensitive } +func (s *State) Forward() bool { return s.opts.Forward } +func (s *State) FromCursor() bool { return s.opts.FromCursor } + +// Switches returns the mode / case / direction / origin toggles. Render and the +// mouse hit-test both build from this, so they cannot drift. +func (s *State) Switches() []Switch { + dir := "→ forward" + if !s.opts.Forward { + dir = "← backward" + } + origin := "cursor" + if !s.opts.FromCursor { + if s.opts.Forward { + origin = "start" + } else { + origin = "end" + } + } + caseTag := "insensitive" + if s.opts.CaseSensitive { + caseTag = "sensitive" + } + return []Switch{ + {"mode", s.opts.Mode.String()}, + {"case", caseTag}, + {"dir", dir}, + {"origin", origin}, + } +} + +// toggle flips switch i. Only the case toggle has a side effect on the shell. +func (s *State) toggle(host Host, i int) { + switch i { + case 0: + s.opts.Mode = bytesearch.NextMode(s.opts.Mode) + case 1: + s.opts.CaseSensitive = !s.opts.CaseSensitive + host.SearchCaseChanged() + case 2: + s.opts.Forward = !s.opts.Forward + case 3: + s.opts.FromCursor = !s.opts.FromCursor + } +} + +// ClickAt toggles the switch under a content column on the switch strip, +// reporting whether one was hit. cx is the 0-based column within the overlay's +// content area; the caller has already checked the row. +func (s *State) ClickAt(host Host, cx int) bool { + if cx < 0 { + return false + } + pos := switchIndent + sepW := lipgloss.Width(SwitchSep) + for i, sw := range s.Switches() { + w := lipgloss.Width(sw.Label()) + if cx >= pos && cx < pos+w { + s.toggle(host, i) + return true + } + pos += w + sepW + } + return false +} + +// Update handles one keypress. Anything that isn't a control types into the +// query box. +func (s *State) Update(host Host, msg tea.KeyMsg, key string) tea.Cmd { + switch key { + case "esc": + s.Close() + return nil + case "ctrl+t": + s.toggle(host, 0) + return nil + case "ctrl+i": + s.toggle(host, 1) + return nil + case "ctrl+r": + s.toggle(host, 2) + return nil + case "ctrl+o": + s.toggle(host, 3) + return nil + case "enter": + query := strings.TrimSpace(s.input.Value()) + s.Close() + return host.SubmitSearch(query, s.opts) + } + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return cmd +} + +func (s *State) Render(ctx modal.Context, host Host) string { + s.ensureInput() + rowW := ctx.ListWidth() + // Switch strip (content row SwitchLine) — clickable; geometry shared with + // ClickAt via Switches(). Each switch is a dim name plus the current value in + // an accent pill. Indented one column to line up with the other elements (and + // the goto/find overlays). + var segs []string + for _, sw := range s.Switches() { + segs = append(segs, ctx.ShadowStyle.Render(sw.Name)+" "+ctx.SwitchStyle.Render("⟦"+sw.Value+"⟧")) + } + switches := strings.Join(segs, SwitchSep) + help := ctx.Hint("^T mode · ^I case · ^R dir · ^O origin · ↵ find · n/N next/prev · esc cancel") + + var sb strings.Builder + sb.WriteString(ctx.Title("Search")) + sb.WriteByte('\n') + sb.WriteString(" " + ctx.Hint(host.SearchHint()) + "\n") + sb.WriteByte('\n') + sb.WriteString(" " + s.input.View() + "\n") + sb.WriteByte('\n') + sb.WriteString(" " + switches + "\n") // content row SwitchLine + sb.WriteByte('\n') + sb.WriteString(" " + help) + return ctx.Frame(layout.PadRight(sb.String(), rowW)) +} diff --git a/internal/ui/modals/search/search_test.go b/internal/ui/modals/search/search_test.go new file mode 100644 index 0000000..73a1716 --- /dev/null +++ b/internal/ui/modals/search/search_test.go @@ -0,0 +1,242 @@ +package search + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/rabarbra/exex/internal/bytesearch" + "github.com/rabarbra/exex/internal/ui/modal" +) + +type submitted struct { + query string + opts Options +} + +type fakeHost struct { + submits []submitted + caseChanges int + cmd tea.Cmd +} + +func (h *fakeHost) SetStatus(string, bool) {} +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) SearchHint() string { return "bytes or text" } +func (h *fakeHost) SearchCaseChanged() { h.caseChanges++ } +func (h *fakeHost) SubmitSearch(q string, o Options) tea.Cmd { + h.submits = append(h.submits, submitted{q, o}) + return h.cmd +} + +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{Width: 100, Height: 30, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} +} + +func key() tea.KeyMsg { return tea.KeyMsg(tea.KeyPressMsg{}) } + +func opened() *State { + s := &State{} + in := textinput.New() + in.Prompt = "/ " + s.Init(in) + s.Open() + return s +} + +// TestInitSetsNonZeroDefaults: forward and from-cursor are true, which the zero +// value is not — a State must be initialised, not merely declared. +func TestInitSetsNonZeroDefaults(t *testing.T) { + var bare State + if bare.Forward() || bare.FromCursor() { + t.Fatal("the zero value should not look initialised") + } + s := opened() + if !s.Forward() || !s.FromCursor() || s.CaseSensitive() || s.Mode() != bytesearch.ModeAuto { + t.Errorf("defaults: forward=%v fromCursor=%v case=%v mode=%v", + s.Forward(), s.FromCursor(), s.CaseSensitive(), s.Mode()) + } +} + +// TestOpenClearsQueryButKeepsOptions: options are search settings, the query is +// per-prompt. +func TestOpenClearsQueryButKeepsOptions(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "ctrl+r") // backward + s.HandleInput(tea.PasteMsg{Content: "stale"}) + if s.Value() != "stale" { + t.Fatalf("paste did not reach the query box: %q", s.Value()) + } + s.Open() + if s.Value() != "" { + t.Errorf("Open kept a stale query: %q", s.Value()) + } + if s.Forward() { + t.Error("Open reset the direction toggle") + } +} + +func TestEnterSubmitsTrimmedQueryWithOptions(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "ctrl+o") // origin: start/end + s.HandleInput(tea.PasteMsg{Content: " de ad "}) + + s.Update(host, key(), "enter") + if len(host.submits) != 1 { + t.Fatalf("submits = %v, want one", host.submits) + } + if host.submits[0].query != "de ad" { + t.Errorf("query = %q, want trimmed", host.submits[0].query) + } + if host.submits[0].opts.FromCursor { + t.Error("the origin toggle did not reach the search") + } + if s.Active() { + t.Error("Enter did not close the prompt") + } +} + +func TestEnterPropagatesTheHostCommand(t *testing.T) { + s := opened() + host := &fakeHost{cmd: func() tea.Msg { return nil }} + if got := s.Update(host, key(), "enter"); got == nil { + t.Error("Update dropped the host's command; the search would never run") + } +} + +func TestEscClosesWithoutSearching(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "esc") + if s.Active() || len(host.submits) != 0 { + t.Errorf("esc: active=%v submits=%v", s.Active(), host.submits) + } +} + +func TestModeCyclesAutoTextHex(t *testing.T) { + s := opened() + host := &fakeHost{} + want := []bytesearch.Mode{bytesearch.ModeText, bytesearch.ModeHex, bytesearch.ModeAuto} + for i, w := range want { + s.Update(host, key(), "ctrl+t") + if s.Mode() != w { + t.Errorf("step %d: mode = %v, want %v", i, s.Mode(), w) + } + } +} + +// TestCaseToggleNotifiesTheHost: cached disasm hits were computed under the old +// setting, so the shell must be told to drop them. Nothing else does. +func TestCaseToggleNotifiesTheHost(t *testing.T) { + s := opened() + host := &fakeHost{} + + s.Update(host, key(), "ctrl+i") + if !s.CaseSensitive() || host.caseChanges != 1 { + t.Errorf("case: sensitive=%v hostNotified=%d", s.CaseSensitive(), host.caseChanges) + } + // The other toggles have no such side effect. + s.Update(host, key(), "ctrl+t") + s.Update(host, key(), "ctrl+r") + s.Update(host, key(), "ctrl+o") + if host.caseChanges != 1 { + t.Errorf("an unrelated toggle notified the host (%d times)", host.caseChanges) + } +} + +// TestOriginLabelFollowsDirection: with the origin off the cursor, "start" and +// "end" depend on which way the search runs. +func TestOriginLabelFollowsDirection(t *testing.T) { + s := opened() + host := &fakeHost{} + labels := func() map[string]string { + out := map[string]string{} + for _, sw := range s.Switches() { + out[sw.Name] = sw.Value + } + return out + } + if got := labels()["origin"]; got != "cursor" { + t.Errorf("origin = %q, want cursor", got) + } + s.Update(host, key(), "ctrl+o") // off the cursor, still forward + if got := labels()["origin"]; got != "start" { + t.Errorf("forward origin = %q, want start", got) + } + s.Update(host, key(), "ctrl+r") // backward + if got := labels()["origin"]; got != "end" { + t.Errorf("backward origin = %q, want end", got) + } +} + +// TestClickAtMapsColumnsToSwitches walks the strip the way the renderer lays it +// out, so a change to one without the other fails here. +func TestClickAtMapsColumnsToSwitches(t *testing.T) { + s := opened() + host := &fakeHost{} + + pos := 1 // switchIndent + sepW := lipgloss.Width(SwitchSep) + centres := map[string]int{} + for _, sw := range s.Switches() { + w := lipgloss.Width(sw.Label()) + centres[sw.Name] = pos + w/2 + pos += w + sepW + } + + if !s.ClickAt(host, centres["mode"]) || s.Mode() != bytesearch.ModeText { + t.Errorf("mode click: mode = %v", s.Mode()) + } + if !s.ClickAt(host, centres["case"]) || !s.CaseSensitive() { + t.Error("case click did not toggle case sensitivity") + } + if !s.ClickAt(host, centres["dir"]) || s.Forward() { + t.Error("dir click did not toggle direction") + } + if !s.ClickAt(host, centres["origin"]) || s.FromCursor() { + t.Error("origin click did not toggle origin") + } +} + +func TestClickAtOutsideAnySwitchIsInert(t *testing.T) { + s := opened() + host := &fakeHost{} + before := s.Options() + for _, cx := range []int{-1, 0, 1000} { + if s.ClickAt(host, cx) { + t.Errorf("ClickAt(%d) reported a hit", cx) + } + } + if s.Options() != before { + t.Error("a missed click changed the options") + } +} + +func TestRenderShowsHintAndSwitches(t *testing.T) { + s := opened() + out := s.Render(testCtx(), &fakeHost{}) + for _, want := range []string{"Search", "bytes or text", "mode", "⟦auto⟧", "case", "⟦insensitive⟧", "dir", "origin"} { + if !strings.Contains(out, want) { + t.Errorf("rendered prompt is missing %q", want) + } + } +} + +// TestSwitchLineMatchesRender pins the constant the mouse hit-test relies on: the +// strip really is on content row SwitchLine. +func TestSwitchLineMatchesRender(t *testing.T) { + s := opened() + lines := strings.Split(s.Render(testCtx(), &fakeHost{}), "\n") + if SwitchLine >= len(lines) { + t.Fatalf("SwitchLine %d is past the rendered %d lines", SwitchLine, len(lines)) + } + if !strings.Contains(lines[SwitchLine], "⟦auto⟧") { + t.Errorf("content row %d is not the switch strip: %q", SwitchLine, lines[SwitchLine]) + } +} diff --git a/internal/ui/modals/settings/settings.go b/internal/ui/modals/settings/settings.go new file mode 100644 index 0000000..23774c0 --- /dev/null +++ b/internal/ui/modals/settings/settings.go @@ -0,0 +1,302 @@ +// Package settings is the settings overlay: a scrollable list of preferences +// grouped under headings, cycled left/right, applied live, and saved on Enter. +// +// The overlay owns the field table, the scroll geometry, the key handling and +// the row→field mapping its mouse hit-test needs. What it does *not* own is what +// a change means: cycling "Symbols as tree" has to rebuild the symbols view, +// "Address width" invalidates every row cache, and "Theme" rebuilds the whole +// palette. Those effects reach across the entire shell, so they stay there and +// this package reaches them through Host. +package settings + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/theme" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// FieldCount is the number of settings rows; it must match the Metas table and +// the shell's CycleSetting switch. +const FieldCount = 16 + +// Meta describes one setting for display: which group it belongs to (so the +// modal can draw section headers), its label and a one-line explanation. The +// slice order matches the field index, and groups are contiguous so a header is +// drawn once per block. +type Meta struct { + Group, Label, Desc string +} + +var Metas = [FieldCount]Meta{ + {"Appearance", "Theme", "colour palette for the whole UI"}, + {"Appearance", "Panel background", "solid fill behind the view panels"}, + {"Appearance", "Wrap long lines", "soft-wrap rows wider than the window"}, + {"Startup", "Open in view", "the view shown when a file loads"}, + {"Startup", "Disasm target", "where Disasm lands: entry/main/start/text/lowest"}, + {"Lists & trees", "Symbols as tree", "group symbols by their source path"}, + {"Lists & trees", "Sources as tree", "nest source files into folders"}, + {"Lists & trees", "Libraries as tree", "group libraries by directory"}, + {"Lists & trees", "Start collapsed", "open trees folded to the top level"}, + {"Symbols & names", "Abbreviate args", "shorten long demangled signatures"}, + {"Symbols & names", "Demangle symbols", "show foo::bar() vs raw _ZN3foo…"}, + {"Disassembly", "Show raw bytes", "the machine-code byte column"}, + {"Disassembly", "Show annotations", "inline target, reloc & string notes"}, + {"Disassembly", "Byte spacing", "space-separated vs packed bytes"}, + {"Addresses & hex", "Address width", "narrow 64-bit addrs when the top half is 0"}, + {"Addresses & hex", "Hex bytes / row", "bytes per row in the Hex & Raw views"}, +} + +// DisasmTargets is the cycle for the "Disasm target" setting. +var DisasmTargets = []string{"entry", "main", "start", "text", "lowest"} + +// ViewNames is the cycle for the "Open in view" setting. +var ViewNames = []string{ + "info", "sections", "symbols", "disasm", "hex", "raw", "strings", "libs", "sources", +} + +// ThemeList is the cycle for the "Theme" setting: the four built-in names first, +// then every curated Chroma style. +// +// theme.Names() is generated from the same manifest that decides which style +// XMLs get embedded, so every entry here has a real highlighter behind it. The +// three built-ins that are also Chroma styles (nord, solarized-dark, +// solarized-light) are deduplicated so they appear once, as built-ins. +func ThemeList(defaultTheme string) []string { + out := []string{defaultTheme, "dark", "solarized-dark", "solarized-light"} + seen := map[string]bool{} + for _, n := range out { + seen[n] = true + } + for _, n := range theme.Names() { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out +} + +// CycleIndex returns the index of cur in list stepped by dir (wrapping); a value +// not in the list is treated as index 0. +func CycleIndex(list []string, cur string, dir int) int { + i := 0 + for j, v := range list { + if strings.EqualFold(v, cur) { + i = j + break + } + } + return (i + dir + len(list)) % len(list) +} + +// CycleHexBytesPerRow steps the bytes-per-row preference through 8 → 16 → 32. +func CycleHexBytesPerRow(cur, dir int) int { + steps := []int{8, 16, 32} + i := 1 // default 16 + for j, v := range steps { + if v == cur { + i = j + break + } + } + return steps[(i+dir+len(steps))%len(steps)] +} + +// GroupLead reports whether field i begins a new group (so its header row is +// drawn before it). +func GroupLead(i int) bool { + return i == 0 || Metas[i].Group != Metas[i-1].Group +} + +// RowHeight is the rendered height of field i for the scroll geometry: the value +// row, plus a header line when it leads a group, plus a blank separator before +// every group but the first. +func RowHeight(i int) int { + h := 1 + if GroupLead(i) { + h++ // group header + if i != 0 { + h++ // blank separator above the group + } + } + return h +} + +// Host is what the settings overlay needs from the shell. It is wider than +// modal.Host because reading and applying a setting touches shell state the +// overlay must not own. +type Host interface { + modal.Host + // SettingValue returns field i's current value as a display string. + SettingValue(i int) string + // CycleSetting steps field i by dir and applies the change across the shell. + CycleSetting(i, dir int) + // PersistSettings writes the config file and reports the outcome via + // SetStatus. It does not close the overlay; the caller does. + PersistSettings() +} + +// State is the settings overlay. The zero value is closed. +type State struct { + active bool + cur int // selected field index (0..FieldCount-1) + top int // first visible field when the list is taller than the window + + // lineFields maps each rendered list line (from ListRow) to its field index, + // or -1 for group headers and blank separators. Rebuilt every Render, because + // the mapping depends on the scroll position and the terminal height. + lineFields []int + listRow int +} + +func (s *State) Open() { s.active, s.cur, s.top = true, 0, 0 } +func (s *State) Close() { s.active = false } +func (s *State) Active() bool { return s.active } +func (s *State) ListRow() int { return s.listRow } + +// Cur returns the selected field index. +func (s *State) Cur() int { return s.cur } + +// SetCur selects a field directly (used by tests and the mouse hit-test). +func (s *State) SetCur(i int) { s.cur = layout.Clamp(i, 0, FieldCount-1) } + +// List exposes the field list to the shell's shared mouse handling. Selection +// wraps: the settings list cycles top-to-bottom, unlike the flat result lists. +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.cur, s.top, FieldCount, true, true +} + +// ClickRow selects the field on a clicked content row. Rows do not map 1:1 to +// fields — group headers and separators are interleaved — so the row→field +// mapping recorded during Render is consulted instead of modal.ClickIndex. +func (s *State) ClickRow(listRow int) bool { + if listRow < 0 || listRow >= len(s.lineFields) { + return false + } + if f := s.lineFields[listRow]; f >= 0 { + s.cur = f + return true + } + return false +} + +// Update handles one keypress. +func (s *State) Update(host Host, key string) tea.Cmd { + switch key { + case "esc", ",": + s.Close() + case "up", "k": + s.cur = (s.cur + FieldCount - 1) % FieldCount + case "down", "j", "tab": + s.cur = (s.cur + 1) % FieldCount + case "left", "h": + host.CycleSetting(s.cur, -1) + case "right", "l", " ": + host.CycleSetting(s.cur, 1) + case "enter": + host.PersistSettings() + s.Close() + } + return nil +} + +// Activate is the mouse double-click action: step the selected field forward. +func (s *State) Activate(host Host) tea.Cmd { + host.CycleSetting(s.cur, 1) + return nil +} + +// Column geometry. The value sits *between* its arrows — padding the value out +// to a fixed width instead left the ‹ › fourteen columns apart on "off", which +// read as a divider rather than as a control. The whole ‹ value › block is +// padded instead, so the arrows hug the value and the description column still +// lines up. +const ( + labelW = 17 // widest label ("Libraries as tree") + valW = 20 // widest value ("catppuccin-macchiato") + controlW = valW + 4 // "‹ " + value + " ›" + leftW = 1 + labelW + 2 + controlW + minDescW = 24 // below this a description is more distracting than useful + + // modalChrome is the border + horizontal padding the frame adds around the + // content, which the content must leave room for or the overlay overruns the + // terminal. The old layout could: a 20-character theme name overflowed a + // 16-wide value column and pushed its description off the right edge. + modalChrome = 6 +) + +func (s *State) Render(ctx modal.Context, host Host) string { + descW := ctx.Width - modalChrome - leftW - 2 + showDesc := descW >= minDescW // drop the description column on narrow terminals + // Every emitted line is capped to what the terminal can show. Without this the + // fixed-width control column, and the footer hint, each set the overlay's + // minimum width and pushed it past the right edge on a narrow terminal. + rowCap := max(1, ctx.Width-modalChrome) + + // Window the field list to the terminal height (title/hint/border cost ~8 + // rows) so the popup never overruns a short window; the selection stays + // visible as it scrolls. The budget is counted in rendered lines, which + // include the per-group headers and separators (RowHeight). + total := 0 + for i := range FieldCount { + total += RowHeight(i) + } + visible := layout.Clamp(ctx.Height-8, 5, total) + s.top = layout.VisualTop(s.cur, s.top, FieldCount, visible, RowHeight) + s.listRow = 2 // title(0) + blank(1) → list starts at content row 2 + + desc := func(str string) string { return ctx.ShadowStyle.Render(str) } + group := func(str string) string { return ctx.HeadingStyle.Render(strings.ToUpper(str)) } + + var b strings.Builder + b.WriteString(ctx.Title("Settings")) + b.WriteString("\n\n") + + s.lineFields = s.lineFields[:0] + emit := func(line string, field int) { + b.WriteString(layout.FitANSIWidth(line, rowCap)) + b.WriteByte('\n') + s.lineFields = append(s.lineFields, field) + } + used := 0 + for i := s.top; i < FieldCount; i++ { + h := RowHeight(i) + if i == s.top && GroupLead(i) && i != 0 { + h-- // the leading blank separator is suppressed at the window top + } + if used+h > visible { + break + } + used += h + if GroupLead(i) { + if i != s.top && i != 0 { + emit("", -1) // blank separator above the group + } + emit(" "+group(Metas[i].Group), -1) + } + control := "‹ " + layout.TruncateMiddle(host.SettingValue(i), valW) + " ›" + left := fmt.Sprintf(" %-*s %s", labelW, Metas[i].Label, layout.PadRight(control, controlW)) + left = layout.PadRight(left, leftW) + if i == s.cur { + left = ctx.SelStyle.Render(left) + } + row := left + if showDesc { + row += " " + desc(layout.TruncateMiddle(Metas[i].Desc, descW)) + } + emit(row, i) + } + + b.WriteString("\n") + hint := "↑/↓ field · ←/→ change · Enter save · Esc cancel" + if visible < total { + hint += fmt.Sprintf(" (%d/%d)", s.cur+1, FieldCount) + } + b.WriteString(layout.FitANSIWidth(ctx.Hint(hint), rowCap)) + return ctx.Frame(b.String()) +} diff --git a/internal/ui/modals/settings/settings_test.go b/internal/ui/modals/settings/settings_test.go new file mode 100644 index 0000000..2004c3a --- /dev/null +++ b/internal/ui/modals/settings/settings_test.go @@ -0,0 +1,270 @@ +package settings + +import ( + "fmt" + "strings" + "testing" + + "github.com/rabarbra/exex/internal/ui/modal" +) + +// fakeHost records what the overlay asked the shell to do. Before the extraction +// none of this was reachable without a whole *ui.Model. +type fakeHost struct { + cycles [][2]int // {field, dir} + persists int + statuses []string + values map[int]string +} + +func newHost() *fakeHost { return &fakeHost{values: map[int]string{}} } + +func (h *fakeHost) SetStatus(msg string, isErr bool) { h.statuses = append(h.statuses, msg) } +func (h *fakeHost) LoadDisasmAt(uint64) {} +func (h *fakeHost) CycleSetting(i, dir int) { h.cycles = append(h.cycles, [2]int{i, dir}) } +func (h *fakeHost) PersistSettings() { h.persists++ } +func (h *fakeHost) SettingValue(i int) string { + if v, ok := h.values[i]; ok { + return v + } + return fmt.Sprintf("v%d", i) +} + +func TestMetasCoverEveryField(t *testing.T) { + if len(Metas) != FieldCount { + t.Fatalf("Metas has %d entries, FieldCount is %d", len(Metas), FieldCount) + } + for i, m := range Metas { + if m.Group == "" || m.Label == "" || m.Desc == "" { + t.Errorf("field %d is incompletely described: %+v", i, m) + } + } +} + +// TestGroupsAreContiguous: RowHeight draws one header per block, which is only +// correct if a group's fields are adjacent. +func TestGroupsAreContiguous(t *testing.T) { + seen := map[string]bool{} + for i := range FieldCount { + g := Metas[i].Group + if GroupLead(i) { + if seen[g] { + t.Errorf("group %q resumes at field %d after another group", g, i) + } + seen[g] = true + } + } +} + +func TestRowHeightAccountsForHeadersAndSeparators(t *testing.T) { + if got := RowHeight(0); got != 2 { + t.Errorf("field 0 height = %d, want 2 (header + row, no leading separator)", got) + } + // The first field of any later group carries a header and a separator. + for i := 1; i < FieldCount; i++ { + want := 1 + if GroupLead(i) { + want = 3 + } + if got := RowHeight(i); got != want { + t.Errorf("field %d height = %d, want %d", i, got, want) + } + } +} + +func TestNavigationWrapsBothWays(t *testing.T) { + s := &State{} + s.Open() + host := newHost() + + if !s.Active() || s.Cur() != 0 { + t.Fatalf("Open: active=%v cur=%d", s.Active(), s.Cur()) + } + // Up from the first field wraps to the last: the settings list cycles. + s.Update(host, "up") + if s.Cur() != FieldCount-1 { + t.Errorf("up from field 0 = %d, want %d (wrap)", s.Cur(), FieldCount-1) + } + s.Update(host, "down") + if s.Cur() != 0 { + t.Errorf("down from the last field = %d, want 0 (wrap)", s.Cur()) + } + s.Update(host, "tab") + if s.Cur() != 1 { + t.Errorf("tab = %d, want 1", s.Cur()) + } +} + +func TestUpdateCyclesAndPersists(t *testing.T) { + s := &State{} + s.Open() + host := newHost() + + s.Update(host, "right") + s.Update(host, " ") + s.Update(host, "left") + want := [][2]int{{0, 1}, {0, 1}, {0, -1}} + if len(host.cycles) != len(want) { + t.Fatalf("cycles = %v, want %v", host.cycles, want) + } + for i := range want { + if host.cycles[i] != want[i] { + t.Errorf("cycle %d = %v, want %v", i, host.cycles[i], want[i]) + } + } + + // Enter saves and closes; the overlay closes itself, the host only saves. + s.Update(host, "enter") + if host.persists != 1 { + t.Errorf("persists = %d, want 1", host.persists) + } + if s.Active() { + t.Error("Enter did not close the overlay") + } +} + +func TestEscAndCommaCloseWithoutSaving(t *testing.T) { + for _, key := range []string{"esc", ","} { + s := &State{} + s.Open() + host := newHost() + s.Update(host, key) + if s.Active() { + t.Errorf("%q did not close the overlay", key) + } + if host.persists != 0 { + t.Errorf("%q saved the config", key) + } + } +} + +func TestActivateCyclesForward(t *testing.T) { + s := &State{} + s.Open() + s.SetCur(5) + host := newHost() + s.Activate(host) + if len(host.cycles) != 1 || host.cycles[0] != [2]int{5, 1} { + t.Errorf("Activate cycles = %v, want [[5 1]]", host.cycles) + } + if !s.Active() { + t.Error("double-click should not close the overlay") + } +} + +func TestSetCurClamps(t *testing.T) { + s := &State{} + s.SetCur(-5) + if s.Cur() != 0 { + t.Errorf("SetCur(-5) = %d, want 0", s.Cur()) + } + s.SetCur(1000) + if s.Cur() != FieldCount-1 { + t.Errorf("SetCur(1000) = %d, want %d", s.Cur(), FieldCount-1) + } +} + +// TestClickRowBeforeRenderHitsNothing: lineFields is built during Render, so a +// click that somehow arrives first must not index into a stale/empty slice. +func TestClickRowBeforeRenderHitsNothing(t *testing.T) { + s := &State{} + s.Open() + for _, row := range []int{-1, 0, 5, 999} { + if s.ClickRow(row) { + t.Errorf("ClickRow(%d) hit an item before any Render", row) + } + } +} + +func TestCycleIndexWrapsAndToleratesUnknown(t *testing.T) { + list := []string{"a", "b", "c"} + if got := CycleIndex(list, "b", 1); got != 2 { + t.Errorf("CycleIndex(b,+1) = %d, want 2", got) + } + if got := CycleIndex(list, "c", 1); got != 0 { + t.Errorf("CycleIndex(c,+1) = %d, want 0 (wrap)", got) + } + if got := CycleIndex(list, "a", -1); got != 2 { + t.Errorf("CycleIndex(a,-1) = %d, want 2 (wrap)", got) + } + // Case-insensitive, and an unknown value is treated as index 0. + if got := CycleIndex(list, "B", 1); got != 2 { + t.Errorf("CycleIndex(B,+1) = %d, want 2 (case-insensitive)", got) + } + if got := CycleIndex(list, "zzz", 1); got != 1 { + t.Errorf("CycleIndex(unknown,+1) = %d, want 1", got) + } +} + +func TestCycleHexBytesPerRow(t *testing.T) { + if got := CycleHexBytesPerRow(16, 1); got != 32 { + t.Errorf("16+1 = %d, want 32", got) + } + if got := CycleHexBytesPerRow(32, 1); got != 8 { + t.Errorf("32+1 = %d, want 8 (wrap)", got) + } + if got := CycleHexBytesPerRow(8, -1); got != 32 { + t.Errorf("8-1 = %d, want 32 (wrap)", got) + } + // An unset (zero) preference behaves as the 16 default. + if got := CycleHexBytesPerRow(0, 1); got != 32 { + t.Errorf("0+1 = %d, want 32", got) + } +} + +func TestThemeListPutsBuiltinsFirstAndDedupes(t *testing.T) { + list := ThemeList("nord") + if len(list) < 4 { + t.Fatalf("theme list too short: %v", list) + } + for i, want := range []string{"nord", "dark", "solarized-dark", "solarized-light"} { + if list[i] != want { + t.Errorf("list[%d] = %q, want %q", i, list[i], want) + } + } + seen := map[string]int{} + for _, n := range list { + seen[n]++ + } + for _, n := range []string{"nord", "solarized-dark", "solarized-light"} { + if seen[n] != 1 { + t.Errorf("%q appears %d times; built-ins that are also Chroma styles must be deduped", n, seen[n]) + } + } +} + +// lineWidth counts display columns, skipping ANSI escapes. +func lineWidth(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + continue + } + if s[i]&0xc0 != 0x80 { // count only UTF-8 lead bytes + n++ + } + } + return n +} + +// TestRenderNeverOverrunsTheTerminal: the footer hint used to be written without +// a width cap, so on a narrow terminal the footer — not the content — set the +// overlay's minimum width and pushed it past the right edge. +func TestRenderNeverOverrunsTheTerminal(t *testing.T) { + id := func(s string) string { return s } + for _, w := range []int{200, 100, 76, 60, 50, 40} { + s := &State{} + s.Open() + ctx := modal.Context{Width: w, Height: 30, Styles: &modal.Styles{Title: id, Frame: id, Hint: id}} + out := s.Render(ctx, newHost()) + for i, line := range strings.Split(out, "\n") { + if got := lineWidth(line); got > w { + t.Errorf("width %d: line %d is %d columns wide", w, i, got) + break + } + } + } +} diff --git a/internal/ui/modals/syscalls/syscalls.go b/internal/ui/modals/syscalls/syscalls.go new file mode 100644 index 0000000..961fe97 --- /dev/null +++ b/internal/ui/modals/syscalls/syscalls.go @@ -0,0 +1,791 @@ +// Package syscalls is the system-calls results overlay: every place the binary +// enters the kernel, grouped by scope (this function / the whole binary / one row +// per distinct call / the binary plus its linked libraries), sortable, filterable +// and followable with Enter. +// +// Finding the sites is analysis (internal/dump) and running the two scans off the +// UI goroutine is the shell's. This package owns what the results look like, how +// they respond to input, and which scope is showing — including the fact that the +// full scope's scan is started lazily the first time it is selected. +package syscalls + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Scope selects which sites the overlay lists. +type Scope uint8 + +const ( + ScopeFunc Scope = iota // only the function under the cursor + ScopeAll // every site in the binary + ScopeUnique // one row per distinct syscall number + ScopeFull // unique across the binary + its linked libs + ScopeCount +) + +// SortKey selects how the overlay orders its rows. Each key has a "natural" +// direction (number/name/address ascending, count descending) that `r` reverses. +type SortKey uint8 + +const ( + SortNumber SortKey = iota // grouped: numbered (by number), vDSO, unresolved + SortName // resolved name (A→Z) + SortCount // occurrences (most-used first) + SortAddr // first site's address (execution order) + SortKeyCount +) + +func (k SortKey) String() string { + switch k { + case SortName: + return "name" + case SortCount: + return "count" + case SortAddr: + return "address" + default: + return "number" + } +} + +// Row is one displayed line: a representative site and, in the aggregated scopes, +// how many sites share its number. +type Row struct { + Site dump.SyscallSite + Count int +} + +// Host is what the overlay needs from the shell beyond the modal base. +type Host interface { + modal.Host + // StartFullScan launches the (lazy) binary + libraries scan. + StartFullScan() tea.Cmd + // CancelFullScan abandons it, when leaving the full scope or jumping away. + CancelFullScan() +} + +// State is the results overlay. Call SetInput once before use. +type State struct { + active bool + + results []dump.SyscallSite + shown []Row + sel int + top int + total int // rows in the active scope before the text filter + + scope Scope + sort SortKey + sortDesc bool + filter textinput.Model + filtering bool + + // The function under the cursor when the scan was launched, so its sites can + // be marked and pre-selected. + fnLo, fnHi uint64 + fnName string + + // Full scope (binary + linked libraries), scanned lazily off-thread. The + // overlay renders its progress, so it holds the display side of that state. + full []dump.SyscallSite + fullNotes []string // libraries that couldn't be scanned + fullObjs int // objects scanned + fullDone bool + fullRunning bool + + listRow int +} + +// SetInput installs the filter widget. The shell owns its styling, so it builds +// it and hands it over. +func (s *State) SetInput(in textinput.Model) { s.filter = in } + +// ensureInput guarantees the filter is constructed before it is focused or +// rendered, so a State built without SetInput can't panic. +func (s *State) ensureInput() { + if s.filter.Prompt == "" { + s.filter = textinput.New() + s.filter.Prompt = "/ " + } +} + +// SetFunc records the function the scan was launched from. +func (s *State) SetFunc(lo, hi uint64, name string) { s.fnLo, s.fnHi, s.fnName = lo, hi, name } + +// FuncName is the name of that function, or "". +func (s *State) FuncName() string { return s.fnName } + +// InFunc reports whether addr is inside it. +func (s *State) InFunc(addr uint64) bool { + return s.fnHi > s.fnLo && addr >= s.fnLo && addr < s.fnHi +} + +// CountInFunc is how many of the given sites fall inside it. +func (s *State) CountInFunc(sites []dump.SyscallSite) int { + n := 0 + for _, site := range sites { + if s.InFunc(site.Addr) { + n++ + } + } + return n +} + +// Open shows the overlay for a finished direct scan. It lands in the function +// scope when the cursor's function has syscalls (the common "what does this +// function call?" question); otherwise it shows them all. +func (s *State) Open(sites []dump.SyscallSite) { + s.results = sites + s.sel, s.top = 0, 0 + s.resetFilter() + if s.CountInFunc(sites) > 0 { + s.scope = ScopeFunc + } else { + s.scope = ScopeAll + } + s.rebuild() + s.active = true +} + +// OpenFull shows the overlay straight in full (binary + libs) scope, for a binary +// with no direct syscall sites of its own. It reports whether the caller must +// start the library scan. +// +// A macOS executable never traps to the kernel itself — its syscalls live in +// libsystem_kernel, reached through the dyld shared cache — so rather than a bare +// "none found" that hides where the syscalls actually are, the transitive scan is +// surfaced. A statically linked ELF with none of its own works the same way. +func (s *State) OpenFull() (needsScan bool) { + s.results = nil + s.sel, s.top = 0, 0 + s.resetFilter() + s.scope = ScopeFull + s.rebuild() + s.active = true + return !s.fullDone && !s.fullRunning +} + +func (s *State) resetFilter() { + s.ensureInput() + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false +} + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) ListRow() int { return s.listRow } +func (s *State) Sel() int { return s.sel } + +// Scope returns the active scope. +func (s *State) Scope() Scope { return s.scope } + +// SetScope switches scope and rebuilds the rows, without the key handler's +// side effects (starting or cancelling the library scan). +func (s *State) SetScope(sc Scope) { + s.scope = sc + s.sel, s.top = 0, 0 + s.rebuild() +} + +// FullSites returns the library-scan results collected so far. +func (s *State) FullSites() []dump.SyscallSite { return s.full } + +// Shown returns how many rows the active scope + filter leave visible. +func (s *State) Shown() int { return len(s.shown) } + +// Rows returns the visible rows. +func (s *State) Rows() []Row { return s.shown } + +// Filtering reports whether the filter box has the keyboard. +func (s *State) Filtering() bool { return s.filtering } + +// FullDone reports whether it has completed. +func (s *State) FullDone() bool { return s.fullDone } + +// SetFullRunning records that the library scan has started, or been abandoned. +func (s *State) SetFullRunning(running bool) { s.fullRunning = running } + +// SetFullResults records a finished library scan and rebuilds the rows. +func (s *State) SetFullResults(sites []dump.SyscallSite, notes []string, objs int) { + s.full, s.fullNotes, s.fullObjs = sites, notes, objs + s.fullDone, s.fullRunning = true, false + s.rebuild() +} + +// RelabelSymbols re-resolves every direct site's containing-symbol name, for when +// the shell's symbol display form changes while the overlay is open. +func (s *State) RelabelSymbols(displayAt func(addr uint64) string) { + for i := range s.results { + s.results[i].Sym = displayAt(s.results[i].Addr) + } + s.rebuild() +} + +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, s.top, len(s.shown), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, s.top, len(s.shown), listRow) +} + +// rebuild recomputes the displayed rows for the active scope, sort and filter. +func (s *State) rebuild() { + rows := s.shown[:0] + switch s.scope { + case ScopeFunc: + for _, site := range s.results { + if s.InFunc(site.Addr) { + rows = append(rows, Row{Site: site, Count: 1}) + } + } + case ScopeUnique: + rows = uniqueRows(s.results, rows) + case ScopeFull: + rows = uniqueRows(s.full, rows) + default: // ScopeAll + for _, site := range s.results { + rows = append(rows, Row{Site: site, Count: 1}) + } + } + sortRows(rows, s.sort, s.sortDesc) + s.total = len(rows) + + // Apply the free-text filter (compacting in place — the kept index never + // overtakes the read index, so the shared backing array is safe to reuse). + if needle := strings.ToLower(strings.TrimSpace(s.filter.Value())); needle != "" { + kept := rows[:0] + for _, r := range rows { + if rowMatches(r, needle) { + kept = append(kept, r) + } + } + rows = kept + } + s.shown = rows + if s.sel >= len(rows) { + s.sel = max(0, len(rows)-1) + } +} + +// sortRows orders rows by the chosen key. The default (number) groups them like +// the dump: numbered first (ascending), then vDSO, then unresolved. +func sortRows(rows []Row, key SortKey, desc bool) { + less := func(i, j int) bool { + a, b := rows[i], rows[j] + switch key { + case SortName: + if a.Site.Name != b.Site.Name { + return a.Site.Name < b.Site.Name + } + return numberLess(a.Site, b.Site) + case SortCount: + if a.Count != b.Count { + return a.Count > b.Count // most-used first + } + return numberLess(a.Site, b.Site) + case SortAddr: + return a.Site.Addr < b.Site.Addr + default: // SortNumber + return numberLess(a.Site, b.Site) + } + } + sort.SliceStable(rows, func(i, j int) bool { + if desc { + return less(j, i) + } + return less(i, j) + }) +} + +// numberLess is the dump's canonical order: numbered sites first (by number), +// then vDSO calls, then unresolved sites (by instruction text). +func numberLess(a, b dump.SyscallSite) bool { + if a.HasNum != b.HasNum { + return a.HasNum + } + if a.HasNum { + return a.Num < b.Num + } + if a.VDSO != b.VDSO { + return a.VDSO + } + return a.Text < b.Text +} + +// rowMatches reports whether a row matches the (lower-cased) filter needle, +// testing the resolved name, the number (decimal and 0x hex), the symbol/origin +// and the instruction text — so "write", "4", "0x4" or "_start" all narrow. +func rowMatches(r Row, needle string) bool { + s := r.Site + if layout.ContainsFold(s.Name, needle) || layout.ContainsFold(s.Sym, needle) || + layout.ContainsFold(s.Origin, needle) || layout.ContainsFold(s.Text, needle) { + return true + } + if s.HasNum { + if layout.ContainsFold(strconv.FormatInt(s.Num, 10), needle) || + layout.ContainsFold("0x"+strconv.FormatInt(s.Num, 16), needle) { + return true + } + } + return s.VDSO && layout.ContainsFold("vdso", needle) +} + +// category classifies a site for colouring: resolved-to-a-name, number-only +// (known number but no table entry), vDSO, or unresolved. +type category uint8 + +const ( + catNamed category = iota // number resolved to a table name + catNumberOnly // number known, not in the table + catVDSO // vDSO / __kernel_ helper call + catUnresolved // couldn't recover the number +) + +func categoryOf(s dump.SyscallSite) category { + switch { + case s.HasNum && s.Name != "": + return catNamed + case s.HasNum: + return catNumberOnly + case s.VDSO: + return catVDSO + default: + return catUnresolved + } +} + +func catStyle(ctx modal.Context, c category) lipgloss.Style { + switch c { + case catNamed: + return ctx.InfoStyle // green + case catNumberOnly: + return ctx.WarnStyle // yellow + case catVDSO: + return ctx.AccentStyle // blue/cyan + default: + return ctx.ShadowStyle // dim + } +} + +// uniqueRows aggregates sites into one row per distinct syscall (number, or +// vDSO/unresolved text), counting occurrences. The first site of each kind is +// kept as the representative (carrying its origin, for the full scope). +func uniqueRows(sites []dump.SyscallSite, rows []Row) []Row { + idx := make(map[string]int, len(sites)) + var key [24]byte + for _, s := range sites { + var k string + switch { + case s.HasNum: + k = "n" + string(strconv.AppendInt(key[:0], s.Num, 10)) + case s.VDSO: + k = "v" + s.Text + default: + k = "u" + s.Text + } + if j, ok := idx[k]; ok { + rows[j].Count++ + continue + } + idx[k] = len(rows) + rows = append(rows, Row{Site: s, Count: 1}) + } + return rows +} + +// ScopeLabel names the active scope, for the status line and the subtitle. +func (s *State) ScopeLabel() string { + switch s.scope { + case ScopeFunc: + if s.fnName != "" { + return "in " + s.fnName + } + return "this function" + case ScopeUnique: + return "unique" + case ScopeFull: + if s.fullRunning { + return "full (+libs) — scanning…" + } + if s.fullDone { + return fmt.Sprintf("full · binary + %d libs", max(0, s.fullObjs-1)) + } + return "full (+libs)" + default: + return "whole binary" + } +} + +// Update drives the results list. When the filter box is focused, typing edits it +// and only the arrows/Enter/Esc/Tab are special; otherwise t cycles scope, s/r +// sort, / focuses the filter, Enter jumps and Esc closes. +func (s *State) Update(host Host, msg tea.KeyMsg, key string) tea.Cmd { + s.ensureInput() + rows := s.shown + if s.filtering { + switch key { + case "esc": // clear the filter and leave the box (overlay stays open) + s.clearFilter() + return nil + case "up": + if s.sel > 0 { + s.sel-- + } + return nil + case "down": + if s.sel < len(rows)-1 { + s.sel++ + } + return nil + case "enter": + return s.Activate(host) + default: + if key == "tab" { // commit the filter, return to command keys + s.filter.Blur() + s.filtering = false + return nil + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.sel, s.top = 0, 0 + s.rebuild() + return cmd + } + } + + switch key { + case "esc": + s.Close() + case "/": + s.filtering = true + return s.filter.Focus() + case "t", "tab", "shift+tab": + oldScope := s.scope + if key == "shift+tab" { + s.scope = (s.scope + ScopeCount - 1) % ScopeCount + } else { + s.scope = (s.scope + 1) % ScopeCount + } + if oldScope == ScopeFull && s.scope != ScopeFull { + host.CancelFullScan() + } + s.sel, s.top = 0, 0 + s.rebuild() + host.SetStatus("syscalls: "+s.ScopeLabel(), false) + // Entering full scope kicks off the (lazy) binary + libraries scan. + if s.scope == ScopeFull && !s.fullDone && !s.fullRunning { + return host.StartFullScan() + } + case "s": + s.sort = (s.sort + 1) % SortKeyCount + s.sel, s.top = 0, 0 + s.rebuild() + host.SetStatus("sort: "+s.sort.String(), false) + case "r": + s.sortDesc = !s.sortDesc + s.sel, s.top = 0, 0 + s.rebuild() + case "up", "k": + if s.sel > 0 { + s.sel-- + } + case "down", "j": + if s.sel < len(rows)-1 { + s.sel++ + } + case "enter": + return s.Activate(host) + } + return nil +} + +// clearFilter empties the filter, defocuses it and rebuilds the rows. +func (s *State) clearFilter() { + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false + s.sel, s.top = 0, 0 + s.rebuild() +} + +// Activate follows the selected site to the disassembly, refusing sites that live +// in a linked library (a different address space). +func (s *State) Activate(host Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.shown) { + return nil + } + site := s.shown[s.sel].Site + if site.Origin != "" && site.Origin != "this binary" { + host.SetStatus("site is in "+site.Origin+" — open it as primary to inspect", true) + return nil + } + s.Close() + host.CancelFullScan() + host.LoadDisasmAt(site.Addr) + return nil +} + +// scopeBar renders the four scopes as a segmented control with the active one +// highlighted, so the t-cycle's options and current position are explicit rather +// than hidden behind a keystroke. +func (s *State) scopeBar(ctx modal.Context) string { + names := [ScopeCount]string{"function", "binary", "unique", "full+libs"} + var b strings.Builder + b.WriteString(ctx.Hint("scope ")) + for i, n := range names { + if i > 0 { + b.WriteString(ctx.ShadowStyle.Render(" ")) + } + if Scope(i) == s.scope { + b.WriteString(ctx.SelStyle.Render(" " + n + " ")) + } else { + b.WriteString(ctx.ShadowStyle.Render(" " + n + " ")) + } + } + if s.scope == ScopeFunc && s.fnName != "" { + b.WriteString(ctx.Hint(" " + s.fnName)) + } + return b.String() +} + +// legend renders the colour key (named / num-only / vdso / unresolved) and the +// active sort, so the row colouring is self-explanatory. +func (s *State) legend(ctx modal.Context) string { + sep := ctx.ShadowStyle.Render(" · ") + dir := "↑" + if s.sortDesc { + dir = "↓" + } + return catStyle(ctx, catNamed).Render("named") + sep + + catStyle(ctx, catNumberOnly).Render("num-only") + sep + + catStyle(ctx, catVDSO).Render("vdso") + sep + + catStyle(ctx, catUnresolved).Render("unresolved") + + ctx.Hint(" sort: "+s.sort.String()+dir) +} + +// Name-column bounds. The column sizes itself to the widest label actually +// present, because a fixed 16 truncated real syscall names ("kdebug_trace_string" +// became "kd…e_string") while the origin column beside it sat half empty. The +// extra width comes out of the origin column, which pads generously. +const ( + minNameW = 16 + maxNameW = 32 +) + +// labelText is the syscall's display label: "# ", or whichever part +// is known. +func labelText(s dump.SyscallSite) string { + switch { + case s.Name != "" && s.HasNum: + return fmt.Sprintf("#%d %s", s.Num, s.Name) + case s.Name != "": + return s.Name + case s.HasNum: + return fmt.Sprintf("#%d", s.Num) + case s.VDSO: + return "vdso" + } + return "" +} + +// labelWidth is labelText's display width, computed arithmetically so sizing the +// column over every row costs no allocations. +func labelWidth(s dump.SyscallSite) int { + switch { + case s.Name != "" && s.HasNum: + return 1 + digits(s.Num) + 1 + len(s.Name) + case s.Name != "": + return len(s.Name) + case s.HasNum: + return 1 + digits(s.Num) + case s.VDSO: + return len("vdso") + } + return 0 +} + +func digits(n int64) int { + if n < 0 { + return 1 + digits(-n) + } + d := 1 + for n >= 10 { + n /= 10 + d++ + } + return d +} + +// nameColumnWidth sizes the name column to the widest label in *every* row, not +// just the visible ones, so the columns don't shift as the list scrolls. +func nameColumnWidth(rows []Row) int { + w := minNameW + for i := range rows { + if lw := labelWidth(rows[i].Site); lw > w { + w = lw + } + } + return min(w, maxNameW) +} + +// noteLines is how many lines the unresolved-libraries note will occupy, so the +// row area can be shrunk to keep the whole overlay within the terminal. +func (s *State) noteLines() int { + if s.scope != ScopeFull || len(s.fullNotes) == 0 { + return 0 + } + n := 2 + min(len(s.fullNotes), 4) // blank + header + up to 4 libs + if len(s.fullNotes) > 4 { + n++ // "… and N more" + } + return n +} + +func (s *State) Render(ctx modal.Context) string { + s.ensureInput() + var sb strings.Builder + addrW := ctx.AddrHexWidth() + rowW := ctx.ListWidth() + // Size the row area so the whole overlay fits the terminal height. Chrome is: + // border(2) + padding(2) + title + blank + scope + filter + legend + blank + // (6 header) + blank + footer (2) = 14 lines, plus the unresolved-library notes + // when shown — subtract all of it so the overlay shrinks instead of overflowing. + visible := layout.Clamp(ctx.Height-14-s.noteLines(), 3, 40) + rows := s.shown + if s.sel >= len(rows) { + s.sel = max(0, len(rows)-1) + } + full := s.scope == ScopeFull + aggregated := s.scope == ScopeUnique || full + + // Column budget: "● ". The name + // column takes what it needs; the origin column absorbs the difference. + nameW := nameColumnWidth(rows) + avail := rowW - len("● ") - (2 + addrW) - len(" ") - nameW - len(" ") - len(" ") - 6 + textW := layout.Clamp(avail/3, 10, 32) + symW := max(8, avail-textW) + + // Header: title, scope segmented control, filter box (with shown/total count), + // and the colour/sort legend — then a blank line before the rows. + sb.WriteString(ctx.Title("System calls")) + sb.WriteString("\n\n") + sb.WriteString(" " + layout.FitANSIWidth(s.scopeBar(ctx), rowW-1)) + sb.WriteString("\n") + countStr := fmt.Sprintf(" %d", len(rows)) + if s.total != len(rows) { + countStr = fmt.Sprintf(" %d of %d", len(rows), s.total) + } + s.filter.SetWidth(layout.Clamp(rowW-len(countStr)-4, 12, 60)) + sb.WriteString(" " + layout.FitANSIWidth(s.filter.View()+ctx.Hint(countStr), rowW-1)) + sb.WriteString("\n") + sb.WriteString(" " + layout.FitANSIWidth(s.legend(ctx), rowW-1)) + sb.WriteString("\n\n") + s.listRow = 6 // title + blank + scope + filter + legend + blank + s.top = layout.VisualTop(s.sel, s.top, len(rows), visible, func(int) int { return 1 }) + // Always emit exactly `visible` rows (padding with blanks past the last hit) so + // the overlay keeps a constant height and doesn't bounce vertically as the + // filter narrows the list. + blankRow := layout.PadVisual("", rowW) + // No rows (an over-narrow filter, or a still-running full scan): a single + // centred message in the middle of the reserved row area. + emptyMsg := "" + if len(rows) == 0 { + switch { + case full && s.fullRunning: + emptyMsg = "scanning binary + libraries…" + case full && s.fullDone: + emptyMsg = "no syscalls found in the binary or its libraries" + case s.filter.Value() != "": + emptyMsg = "no syscalls match the filter" + default: + emptyMsg = "no syscalls" + } + } + for row := range visible { + i := s.top + row + if i >= len(rows) { + if emptyMsg != "" && row == visible/2 { + sb.WriteString(modal.CenterLine(ctx.ShadowStyle.Render(emptyMsg), rowW)) + } else { + sb.WriteString(blankRow) + } + sb.WriteString("\n") + continue + } + h := rows[i].Site + loc := h.Sym + if full { // in the full scope the originating object is more useful than the symbol + loc = h.Origin + } + if loc == "" { + loc = "—" + } + mark := " " + if !aggregated && s.InFunc(h.Addr) { + mark = "●" + } + text := layout.TruncateMiddle(h.Text, textW) + if h.VDSO { + text += " ·vdso" + } + label := labelText(h) + // Colour the syscall label by resolution category (named / num-only / vdso / + // unresolved) so the eye can pick out which numbers actually mapped to a name. + num := catStyle(ctx, categoryOf(h)).Render(layout.PadVisual(layout.TruncateMiddle(label, nameW), nameW)) + // In aggregated scopes (unique / full) show a use count instead of an address. + first := fmt.Sprintf("0x%0*x", addrW, h.Addr) + if aggregated { + first = layout.PadVisual(fmt.Sprintf("%d×", rows[i].Count), 2+addrW) + } + line := fmt.Sprintf("%s %s %s %s %s", + mark, first, num, + layout.PadVisual(layout.TruncateMiddle(loc, symW), symW), + text) + line = layout.PadVisual(line, rowW) + if i == s.sel { // strip the category colour so the selection bar reads cleanly + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line) + sb.WriteString("\n") + } + // Full scope: list libraries that couldn't be scanned, mirroring the dump. + if full && len(s.fullNotes) > 0 { + sb.WriteString("\n") + sb.WriteString(" " + ctx.WarnStyle.Render(fmt.Sprintf("%d unresolved libraries:", len(s.fullNotes))) + "\n") + shown := s.fullNotes + if len(shown) > 4 { + shown = shown[:4] + } + for _, n := range shown { + sb.WriteString(" " + ctx.ShadowStyle.Render(layout.FitANSIWidth(n, rowW)) + "\n") + } + if len(s.fullNotes) > 4 { + sb.WriteString(" " + ctx.ShadowStyle.Render(fmt.Sprintf(" … and %d more", len(s.fullNotes)-4)) + "\n") + } + } + + sb.WriteString("\n") + footer := fmt.Sprintf("↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (%d/%d)", + min(s.sel+1, len(rows)), len(rows)) + if s.filtering { + footer = fmt.Sprintf("type to filter · ↵ jump · Tab done · Esc clear (%d/%d)", + min(s.sel+1, len(rows)), len(rows)) + } + sb.WriteString(ctx.Hint(layout.FitANSIWidth(footer, rowW))) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/syscalls/syscalls_test.go b/internal/ui/modals/syscalls/syscalls_test.go new file mode 100644 index 0000000..5a4940c --- /dev/null +++ b/internal/ui/modals/syscalls/syscalls_test.go @@ -0,0 +1,370 @@ +package syscalls + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/dump" + "github.com/rabarbra/exex/internal/ui/modal" +) + +type fakeHost struct { + loaded []uint64 + statuses []string + errs []bool + fullStarts int + fullCancel int + cmd tea.Cmd +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { + h.statuses = append(h.statuses, msg) + h.errs = append(h.errs, isErr) +} +func (h *fakeHost) LoadDisasmAt(a uint64) { h.loaded = append(h.loaded, a) } +func (h *fakeHost) StartFullScan() tea.Cmd { + h.fullStarts++ + return h.cmd +} +func (h *fakeHost) CancelFullScan() { h.fullCancel++ } + +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{ + File: &binfile.File{}, + Width: 110, + Height: 30, + Styles: &modal.Styles{Title: id, Frame: id, Hint: id}, + } +} + +func key() tea.KeyMsg { return tea.KeyMsg(tea.KeyPressMsg{}) } + +func newState() *State { + s := &State{} + in := textinput.New() + in.Prompt = "/ " + s.SetInput(in) + return s +} + +// sites: two calls to #1 inside the cursor's function, one #60 outside it. +func sites() []dump.SyscallSite { + return []dump.SyscallSite{ + {Addr: 0x1010, Num: 1, HasNum: true, Name: "write", Text: "syscall"}, + {Addr: 0x1020, Num: 1, HasNum: true, Name: "write", Text: "syscall"}, + {Addr: 0x2000, Num: 60, HasNum: true, Name: "exit", Text: "syscall"}, + } +} + +func opened() *State { + s := newState() + s.SetFunc(0x1000, 0x1100, "main") + s.Open(sites()) + return s +} + +// TestScopesFilterRows moved here from the shell's newviews_test: function scope +// shows only the cursor's function, unique aggregates by number with counts. +func TestScopesFilterRows(t *testing.T) { + s := opened() + + if s.Scope() != ScopeFunc { + t.Fatalf("Open landed in scope %v, want ScopeFunc (its function has syscalls)", s.Scope()) + } + if s.Shown() != 2 { + t.Errorf("function scope = %d rows, want 2", s.Shown()) + } + s.SetScope(ScopeAll) + if s.Shown() != 3 { + t.Errorf("all scope = %d rows, want 3", s.Shown()) + } + s.SetScope(ScopeUnique) + if s.Shown() != 2 { + t.Fatalf("unique scope = %d rows, want 2 (#1, #60)", s.Shown()) + } + for _, r := range s.Rows() { + if r.Site.Num == 1 && r.Count != 2 { + t.Errorf("unique count for #1 = %d, want 2", r.Count) + } + } +} + +// TestOpenLandsInAllWhenTheFunctionHasNone: the function scope would be empty. +func TestOpenLandsInAllWhenTheFunctionHasNone(t *testing.T) { + s := newState() + s.SetFunc(0x9000, 0x9100, "elsewhere") + s.Open(sites()) + if s.Scope() != ScopeAll { + t.Errorf("scope = %v, want ScopeAll", s.Scope()) + } + if s.Shown() != 3 { + t.Errorf("shown = %d, want 3", s.Shown()) + } +} + +// TestOpenFullReportsWhetherAScanIsNeeded: the library scan is lazy, and must not +// be started twice. +func TestOpenFullReportsWhetherAScanIsNeeded(t *testing.T) { + s := newState() + if !s.OpenFull() { + t.Error("a fresh overlay should ask for the library scan") + } + if s.Scope() != ScopeFull || !s.Active() { + t.Errorf("OpenFull: scope=%v active=%v", s.Scope(), s.Active()) + } + + s.SetFullRunning(true) + if s.OpenFull() { + t.Error("a running scan should not be started again") + } + s.SetFullResults(nil, nil, 1) + if s.OpenFull() { + t.Error("a finished scan should not be started again") + } +} + +// TestEnteringFullScopeStartsTheScanOnce is the lazy-scan contract from the key +// handler's side. +func TestEnteringFullScopeStartsTheScanOnce(t *testing.T) { + s := opened() + host := &fakeHost{cmd: func() tea.Msg { return nil }} + + // ScopeFunc → All → Unique → Full + for range 3 { + s.Update(host, key(), "t") + } + if s.Scope() != ScopeFull { + t.Fatalf("scope = %v, want ScopeFull", s.Scope()) + } + if host.fullStarts != 1 { + t.Errorf("full scans started = %d, want 1", host.fullStarts) + } + + // Leaving full scope cancels it; the results are not yet in. + s.Update(host, key(), "t") + if host.fullCancel != 1 { + t.Errorf("leaving full scope did not cancel the scan (%d)", host.fullCancel) + } +} + +// TestFullScopeDoesNotRescanWhenDone +func TestFullScopeDoesNotRescanWhenDone(t *testing.T) { + s := opened() + s.SetFullResults([]dump.SyscallSite{{Addr: 0x5000, Num: 5, HasNum: true, Name: "read"}}, nil, 2) + host := &fakeHost{} + for range 3 { + s.Update(host, key(), "t") + } + if s.Scope() != ScopeFull { + t.Fatalf("scope = %v", s.Scope()) + } + if host.fullStarts != 0 { + t.Errorf("a completed scan was restarted (%d times)", host.fullStarts) + } + if s.Shown() != 1 { + t.Errorf("full scope shows %d rows, want the library's 1", s.Shown()) + } +} + +func TestSortCyclesAndReverses(t *testing.T) { + s := opened() + s.SetScope(ScopeAll) + host := &fakeHost{} + + if got := s.Rows()[0].Site.Num; got != 1 { + t.Errorf("default sort: first = #%d, want #1 (by number)", got) + } + s.Update(host, key(), "r") // reverse + if got := s.Rows()[0].Site.Num; got != 60 { + t.Errorf("reversed: first = #%d, want #60", got) + } + s.Update(host, key(), "r") // back + s.Update(host, key(), "s") // → name + if got := s.Rows()[0].Site.Name; got != "exit" { + t.Errorf("by name: first = %q, want exit", got) + } + if len(host.statuses) == 0 || !strings.Contains(host.statuses[len(host.statuses)-1], "name") { + t.Errorf("sort change did not report the key: %v", host.statuses) + } +} + +func TestFilterMatchesNameNumberAndText(t *testing.T) { + for _, tc := range []struct { + needle string + want int + }{ + {"write", 2}, + {"exit", 1}, + {"60", 1}, + {"0x3c", 1}, // 60 in hex + {"syscall", 3}, + {"nothing", 0}, + } { + t.Run(tc.needle, func(t *testing.T) { + s := opened() + s.SetScope(ScopeAll) + s.filter.SetValue(tc.needle) + s.rebuild() + if s.Shown() != tc.want { + t.Errorf("filter %q = %d rows, want %d", tc.needle, s.Shown(), tc.want) + } + if s.total != 3 { + t.Errorf("total = %d, want 3 (pre-filter)", s.total) + } + }) + } +} + +func TestActivateJumpsAndCancelsTheLibraryScan(t *testing.T) { + s := opened() + host := &fakeHost{} + s.Update(host, key(), "enter") + if len(host.loaded) != 1 || host.loaded[0] != 0x1010 { + t.Errorf("jumped to %#x, want [0x1010]", host.loaded) + } + if host.fullCancel != 1 { + t.Error("jumping did not cancel the library scan") + } + if s.Active() { + t.Error("Enter did not close the overlay") + } +} + +// TestActivateRefusesSitesInOtherObjects: a library site is in a different +// address space, so following it would land in the wrong image. +func TestActivateRefusesSitesInOtherObjects(t *testing.T) { + s := newState() + s.SetFullResults([]dump.SyscallSite{ + {Addr: 0x5000, Num: 5, HasNum: true, Name: "read", Origin: "libsystem_kernel.dylib"}, + }, nil, 2) + s.OpenFull() + + host := &fakeHost{} + s.Activate(host) + if len(host.loaded) != 0 { + t.Errorf("followed a site in another object: %#x", host.loaded) + } + if len(host.statuses) != 1 || !strings.Contains(host.statuses[0], "libsystem_kernel.dylib") { + t.Errorf("statuses = %v, want an explanation naming the object", host.statuses) + } + if !host.errs[0] { + t.Error("the refusal should be reported as an error") + } + if !s.Active() { + t.Error("a refused jump closed the overlay") + } +} + +func TestEscInFilterOnlyClearsIt(t *testing.T) { + s := opened() + s.SetScope(ScopeAll) + host := &fakeHost{} + + s.Update(host, key(), "/") + if !s.Filtering() { + t.Fatal("/ did not focus the filter") + } + s.filter.SetValue("exit") + s.rebuild() + s.Update(host, key(), "esc") + if s.Filtering() || !s.Active() { + t.Errorf("esc in filter: filtering=%v active=%v", s.Filtering(), s.Active()) + } + if s.Shown() != 3 { + t.Errorf("esc did not clear the filter: shown=%d", s.Shown()) + } + s.Update(host, key(), "esc") + if s.Active() { + t.Error("esc outside the filter did not close the overlay") + } +} + +// TestNameColumnSizesToContent is the fix for truncated syscall names: a fixed +// 16-wide column turned "kdebug_trace_string" into "kd…e_string" while the +// origin column beside it was padded with blanks. +func TestNameColumnSizesToContent(t *testing.T) { + short := []Row{{Site: dump.SyscallSite{Num: 1, HasNum: true, Name: "write"}, Count: 1}} + if got := nameColumnWidth(short); got != minNameW { + t.Errorf("short labels widened the column to %d, want the %d minimum", got, minNameW) + } + + long := []Row{{Site: dump.SyscallSite{Num: 178, HasNum: true, Name: "kdebug_trace_string"}, Count: 1}} + want := len("#178 kdebug_trace_string") + if got := nameColumnWidth(long); got != want { + t.Errorf("column width = %d, want %d (the whole label)", got, want) + } + + huge := []Row{{Site: dump.SyscallSite{Num: 1, HasNum: true, Name: strings.Repeat("x", 100)}, Count: 1}} + if got := nameColumnWidth(huge); got != maxNameW { + t.Errorf("a pathological name widened the column to %d, want the %d cap", got, maxNameW) + } + + // Rendering it must show the name in full. + s := newState() + s.SetFullResults([]dump.SyscallSite{ + {Addr: 0x1, Num: 178, HasNum: true, Name: "kdebug_trace_string", Origin: "libsystem_kernel.dylib", Text: "svc"}, + }, nil, 2) + s.OpenFull() + if out := s.Render(testCtx()); !strings.Contains(out, "#178 kdebug_trace_string") { + t.Errorf("the rendered row truncated the name:\n%s", out) + } +} + +func TestLabelWidthMatchesLabelText(t *testing.T) { + for _, s := range []dump.SyscallSite{ + {Num: 1, HasNum: true, Name: "write"}, + {Num: 1234, HasNum: true}, + {Name: "only_a_name"}, + {VDSO: true}, + {}, + } { + if got, want := labelWidth(s), len(labelText(s)); got != want { + t.Errorf("labelWidth(%+v) = %d, labelText is %d wide", s, got, want) + } + } +} + +func TestScopeLabelDescribesTheFullScan(t *testing.T) { + s := newState() + s.OpenFull() + if got := s.ScopeLabel(); got != "full (+libs)" { + t.Errorf("before the scan: %q", got) + } + s.SetFullRunning(true) + if got := s.ScopeLabel(); !strings.Contains(got, "scanning") { + t.Errorf("during the scan: %q", got) + } + s.SetFullResults(nil, nil, 3) + if got := s.ScopeLabel(); got != "full · binary + 2 libs" { + t.Errorf("after the scan: %q", got) + } +} + +func TestRenderEmptyStates(t *testing.T) { + s := newState() + s.OpenFull() + if !strings.Contains(s.Render(testCtx()), "no syscalls") { + t.Error("an idle empty full scope should say so") + } + s.SetFullRunning(true) + if !strings.Contains(s.Render(testCtx()), "scanning binary + libraries…") { + t.Error("a running scan should say it is scanning, not that there are none") + } + s.SetFullResults(nil, nil, 2) + if !strings.Contains(s.Render(testCtx()), "no syscalls found in the binary or its libraries") { + t.Error("a finished empty scan should say so") + } + + s2 := opened() + s2.SetScope(ScopeAll) + s2.filter.SetValue("zzz") + s2.rebuild() + if !strings.Contains(s2.Render(testCtx()), "no syscalls match the filter") { + t.Error("an over-narrow filter should say so") + } +} diff --git a/internal/ui/modals/textoverlay/textoverlay.go b/internal/ui/modals/textoverlay/textoverlay.go new file mode 100644 index 0000000..27a9f71 --- /dev/null +++ b/internal/ui/modals/textoverlay/textoverlay.go @@ -0,0 +1,74 @@ +// Package textoverlay is the shared behaviour of exex's scrollable text +// overlays: the keybinding cheat-sheet and the raw container header. +// +// Neither has a list or a selection. They show a block of pre-rendered rows, +// page through it when it is taller than the terminal, and dismiss on any key +// that isn't a scroll key. Only the rows and the footer wording differ, so +// everything else lives here. +package textoverlay + +import "github.com/rabarbra/exex/internal/ui/layout" + +// chromeRows is how many rows the title, footer, border and padding cost, and so +// how much of the terminal's height the body cannot use. +const chromeRows = 8 + +// endSentinel is the scroll offset the "end" key sets. Window clamps it to the +// real bottom, which the overlay only knows once its rows are built. +const endSentinel = 1 << 20 + +// Scroller is the state shared by the scrollable text overlays. The zero value is +// closed and scrolled to the top. +type Scroller struct { + active bool + scroll int +} + +func (s *Scroller) Open() { s.active, s.scroll = true, 0 } +func (s *Scroller) Close() { s.active = false } +func (s *Scroller) Active() bool { return s.active } + +// Scroll moves the view by delta rows. The offset is clamped by Window, because +// the row count is not known until the overlay renders. +func (s *Scroller) Scroll(delta int) { s.scroll += delta } + +// ScrollOffset is the current top row, valid after the last Window call. +func (s *Scroller) ScrollOffset() int { return s.scroll } + +// Update handles one keypress: scroll keys page through the rows; any other key +// dismisses the overlay. pageStep is how far PgUp/PgDn move, which differs per +// overlay because their rows differ in density. +func (s *Scroller) Update(key string, pageStep int) { + switch key { + case "up", "k": + s.scroll-- + case "down", "j": + s.scroll++ + case "pgup": + s.scroll -= pageStep + case "pgdown": + s.scroll += pageStep + case "home", "g": + s.scroll = 0 + case "end", "G": + s.scroll = endSentinel // clamped by Window + default: + s.Close() + } +} + +// BodyRows is how many rows of content fit in a terminal of this height. +func BodyRows(height int) int { return max(1, height-chromeRows) } + +// Window clamps the scroll offset to rows and returns the visible slice, plus the +// 1-based range it covers. scrolled is false when everything fits, in which case +// the offset is pinned to the top and the caller should show no range. +func (s *Scroller) Window(rows []string, height int) (visible []string, from, to int, scrolled bool) { + maxRows := BodyRows(height) + if len(rows) <= maxRows { + s.scroll = 0 + return rows, 1, len(rows), false + } + s.scroll = layout.Clamp(s.scroll, 0, len(rows)-maxRows) + return rows[s.scroll : s.scroll+maxRows], s.scroll + 1, s.scroll + maxRows, true +} diff --git a/internal/ui/modals/textoverlay/textoverlay_test.go b/internal/ui/modals/textoverlay/textoverlay_test.go new file mode 100644 index 0000000..ef4748d --- /dev/null +++ b/internal/ui/modals/textoverlay/textoverlay_test.go @@ -0,0 +1,132 @@ +package textoverlay + +import ( + "strconv" + "testing" +) + +const pageStep = 8 + +func rows(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = strconv.Itoa(i) + } + return out +} + +func TestZeroValueIsClosedAtTheTop(t *testing.T) { + var s Scroller + if s.Active() || s.ScrollOffset() != 0 { + t.Errorf("zero value: active=%v scroll=%d", s.Active(), s.ScrollOffset()) + } +} + +func TestOpenResetsScroll(t *testing.T) { + var s Scroller + s.Scroll(30) + s.Open() + if !s.Active() || s.ScrollOffset() != 0 { + t.Errorf("Open: active=%v scroll=%d, want true/0", s.Active(), s.ScrollOffset()) + } +} + +func TestScrollKeysDoNotClose(t *testing.T) { + for _, key := range []string{"up", "k", "down", "j", "pgup", "pgdown", "home", "g", "end", "G"} { + var s Scroller + s.Open() + s.Update(key, pageStep) + if !s.Active() { + t.Errorf("scroll key %q closed the overlay", key) + } + } +} + +// TestAnyOtherKeyDismisses is the documented behaviour: these overlays are a +// glance, not a mode. +func TestAnyOtherKeyDismisses(t *testing.T) { + for _, key := range []string{"esc", "q", "?", "x", "enter", "/"} { + var s Scroller + s.Open() + s.Update(key, pageStep) + if s.Active() { + t.Errorf("%q did not dismiss the overlay", key) + } + } +} + +func TestPageStepIsPerOverlay(t *testing.T) { + var s Scroller + s.Open() + s.Update("pgdown", 10) + if s.ScrollOffset() != 10 { + t.Errorf("pgdown with step 10 = %d", s.ScrollOffset()) + } + s.Update("pgup", 3) + if s.ScrollOffset() != 7 { + t.Errorf("pgup with step 3 = %d, want 7", s.ScrollOffset()) + } +} + +// TestWindowClampsBothEnds: Scroll and the "end" key are deliberately unbounded, +// because the row count is not known until render. Window is what clamps. +func TestWindowClampsBothEnds(t *testing.T) { + const height = 20 // BodyRows = 12 + body := BodyRows(height) + all := rows(30) + + var s Scroller + s.Open() + s.Update("end", pageStep) + if s.ScrollOffset() != endSentinel { + t.Fatalf("end set scroll to %d, want the sentinel", s.ScrollOffset()) + } + visible, from, to, scrolled := s.Window(all, height) + if !scrolled { + t.Fatal("30 rows in a 12-row body should scroll") + } + if s.ScrollOffset() != len(all)-body { + t.Errorf("end clamped to %d, want %d", s.ScrollOffset(), len(all)-body) + } + if len(visible) != body || visible[len(visible)-1] != "29" { + t.Errorf("end did not show the last row: %v", visible) + } + if from != len(all)-body+1 || to != len(all) { + t.Errorf("range %d–%d, want %d–%d", from, to, len(all)-body+1, len(all)) + } + + s.Scroll(-1 << 20) + visible, from, to, _ = s.Window(all, height) + if s.ScrollOffset() != 0 || from != 1 || to != body || visible[0] != "0" { + t.Errorf("scrolling above the top: scroll=%d range=%d–%d first=%q", s.ScrollOffset(), from, to, visible[0]) + } +} + +// TestWindowPinsToTopWhenEverythingFits: with nothing to scroll, a stale offset +// must not hide the first rows. +func TestWindowPinsToTopWhenEverythingFits(t *testing.T) { + var s Scroller + s.Open() + s.Scroll(5) + all := rows(3) + visible, from, to, scrolled := s.Window(all, 100) + if scrolled { + t.Error("3 rows in a tall window should not scroll") + } + if s.ScrollOffset() != 0 || len(visible) != 3 || from != 1 || to != 3 { + t.Errorf("scroll=%d visible=%d range=%d–%d", s.ScrollOffset(), len(visible), from, to) + } +} + +// TestBodyRowsNeverZero: a one-row terminal must still render something rather +// than slicing to an empty window. +func TestBodyRowsNeverZero(t *testing.T) { + for _, h := range []int{0, 1, 8, 9} { + if got := BodyRows(h); got < 1 { + t.Errorf("BodyRows(%d) = %d, want at least 1", h, got) + } + } + if got := BodyRows(20); got != 12 { + t.Errorf("BodyRows(20) = %d, want 12", got) + } +} diff --git a/internal/ui/modals/xref/xref.go b/internal/ui/modals/xref/xref.go new file mode 100644 index 0000000..d4f2bcb --- /dev/null +++ b/internal/ui/modals/xref/xref.go @@ -0,0 +1,359 @@ +// Package xref is the cross-references results overlay: every instruction that +// references the address under the disasm cursor, sortable, filterable, and +// followable with Enter. +// +// Finding the references is analysis (explorer.DisasmService.ScanMatching) and +// running it off the UI goroutine is the shell's, since only the shell owns the +// event loop. This package owns what the results look like and how they respond +// to input. +package xref + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" +) + +// Hit is one referencing instruction. +type Hit struct { + Addr uint64 // address of the instruction making the reference + Text string // its (trimmed) assembly text + Sym string // display name of the symbol it lives in, or "" +} + +// SortKey selects how the overlay orders references. +type SortKey uint8 + +const ( + SortAddr SortKey = iota // referencing address + SortLoc // containing symbol + SortKind // instruction kind (groups calls / jumps / loads) + SortKeyCount +) + +func (k SortKey) String() string { + switch k { + case SortLoc: + return "location" + case SortKind: + return "kind" + default: + return "address" + } +} + +// State is the results overlay. Call SetInput once before use. +type State struct { + active bool + label string // display name of the target (symbol or 0x…) + + results []Hit + shown []int // indices into results after sort + filter + sel int + top int + + sort SortKey + sortDesc bool + filter textinput.Model + filtering bool + total int // results before the text filter + + listRow int +} + +// SetInput installs the filter widget. The shell owns its styling, so it builds +// it and hands it over. +func (s *State) SetInput(in textinput.Model) { s.filter = in } + +// ensureInput guarantees the filter is constructed before it is focused or +// rendered, so a State built without SetInput (a bare test model) can't panic. +func (s *State) ensureInput() { + if s.filter.Prompt == "" { + s.filter = textinput.New() + s.filter.Prompt = "/ " + } +} + +// Open shows the overlay for a finished scan, resetting the filter. +func (s *State) Open(label string, hits []Hit) { + s.label = label + s.results = hits + s.sel, s.top = 0, 0 + s.ensureInput() + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false + s.rebuild() + s.active = true +} + +func (s *State) Active() bool { return s.active } +func (s *State) Close() { s.active = false } +func (s *State) ListRow() int { return s.listRow } + +// Sel returns the selected row index. +func (s *State) Sel() int { return s.sel } + +// SetLabel updates the target's display name, for when symbol names change form +// (the demangle toggle) while the overlay is open. +func (s *State) SetLabel(label string) { s.label = label } + +// RelabelSymbols re-resolves every row's containing-symbol name and re-sorts, for +// when the shell's symbol display form changes (the demangle or abbreviation +// toggles) while the overlay is open. The sort can depend on the name, so the +// rows have to be rebuilt, not just repainted. +func (s *State) RelabelSymbols(displayAt func(addr uint64) string) { + for i := range s.results { + s.results[i].Sym = displayAt(s.results[i].Addr) + } + s.rebuild() +} + +// Filtering reports whether the filter box has the keyboard. +func (s *State) Filtering() bool { return s.filtering } + +func (s *State) List() (sel *int, top, n int, wrap, ok bool) { + return &s.sel, s.top, len(s.shown), false, true +} + +func (s *State) ClickRow(listRow int) bool { + return modal.ClickIndex(&s.sel, s.top, len(s.shown), listRow) +} + +// rebuild recomputes shown (indices into results) for the active sort and filter. +func (s *State) rebuild() { + rows := s.shown[:0] + for i := range s.results { + rows = append(rows, i) + } + desc := s.sortDesc + sort.SliceStable(rows, func(a, b int) bool { + x, y := s.results[rows[a]], s.results[rows[b]] + if desc { + x, y = y, x + } + return less(x, y, s.sort) + }) + s.total = len(rows) + if needle := strings.ToLower(strings.TrimSpace(s.filter.Value())); needle != "" { + kept := rows[:0] + for _, idx := range rows { + if matches(s.results[idx], needle) { + kept = append(kept, idx) + } + } + rows = kept + } + s.shown = rows + if s.sel >= len(rows) { + s.sel = max(0, len(rows)-1) + } +} + +func less(a, b Hit, key SortKey) bool { + switch key { + case SortLoc: + if a.Sym != b.Sym { + return a.Sym < b.Sym + } + return a.Addr < b.Addr + case SortKind: + if ka, kb := kindOf(a.Text), kindOf(b.Text); ka != kb { + return ka < kb + } + return a.Addr < b.Addr + default: + return a.Addr < b.Addr + } +} + +func matches(h Hit, needle string) bool { + return layout.ContainsFold(h.Sym, needle) || layout.ContainsFold(h.Text, needle) || + layout.ContainsFold("0x"+strconv.FormatUint(h.Addr, 16), needle) +} + +// kindOf buckets a referencing instruction so the overlay can colour and sort it: +// 0 call, 1 jump/branch, 2 address-load, 3 other. +func kindOf(text string) int { + op := disasm.Mnemonic(text) + switch { + case strings.HasPrefix(op, "call") || strings.HasPrefix(op, "bl"): + return 0 + case op == "jmp" || op == "b" || (len(op) > 0 && op[0] == 'j') || strings.HasPrefix(op, "b."): + return 1 + case disasm.IsAddrLoad(op): + return 2 + } + return 3 +} + +func kindStyle(ctx modal.Context, text string) lipgloss.Style { + switch kindOf(text) { + case 0: + return ctx.InfoStyle // call → green + case 1: + return ctx.WarnStyle // jump/branch → yellow + case 2: + return ctx.AccentStyle // address load → blue + default: + return ctx.ShadowStyle // other → dim + } +} + +// Update drives the results list. While the filter box is focused, typing edits +// it; otherwise s/r sort, / filters, Enter jumps and Esc closes. +func (s *State) Update(host modal.Host, msg tea.KeyMsg, key string) tea.Cmd { + s.ensureInput() + rows := s.shown + if s.filtering { + switch key { + case "esc": + s.filter.SetValue("") + s.filter.Blur() + s.filtering = false + s.sel, s.top = 0, 0 + s.rebuild() + case "up": + if s.sel > 0 { + s.sel-- + } + case "down": + if s.sel < len(rows)-1 { + s.sel++ + } + case "enter": + return s.Activate(host) + default: + if key == "tab" { + s.filter.Blur() + s.filtering = false + return nil + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.sel, s.top = 0, 0 + s.rebuild() + return cmd + } + return nil + } + switch key { + case "esc": + s.Close() + case "/": + s.filtering = true + return s.filter.Focus() + case "s": + s.sort = (s.sort + 1) % SortKeyCount + s.sel, s.top = 0, 0 + s.rebuild() + host.SetStatus("sort: "+s.sort.String(), false) + case "r": + s.sortDesc = !s.sortDesc + s.sel, s.top = 0, 0 + s.rebuild() + case "up", "k": + if s.sel > 0 { + s.sel-- + } + case "down", "j": + if s.sel < len(rows)-1 { + s.sel++ + } + case "enter": + return s.Activate(host) + } + return nil +} + +// Activate follows the selected reference to its instruction in the disasm view. +func (s *State) Activate(host modal.Host) tea.Cmd { + if s.sel < 0 || s.sel >= len(s.shown) { + return nil + } + addr := s.results[s.shown[s.sel]].Addr + s.Close() + host.LoadDisasmAt(addr) + return nil +} + +func (s *State) Render(ctx modal.Context) string { + s.ensureInput() + var sb strings.Builder + addrW := ctx.AddrHexWidth() + rowW := ctx.ListWidth() + rows := s.shown + visible := layout.Clamp(ctx.Height-10, 3, 40) // 2 extra header lines (filter + legend) + + // Column budget: " 0x ". The instruction text in an xref + // is short (call/lea/branch), so cap it and give the rest to the symbol. + avail := rowW - len(" ") - (2 + addrW) - len(" ") - len(" ") + textW := layout.Clamp(avail/3, 12, 40) + symW := max(8, avail-textW) + + // Title + target name (a possibly long demangled symbol), then a filter box and + // a colour/sort legend before the rows. + sb.WriteString(ctx.Title("Cross-references")) + sb.WriteString("\n") + targetRows := layout.RenderLineRowsIndented(ctx.HeadingStyle.Render(s.label), rowW, true, 0) + for _, r := range targetRows { + sb.WriteString(r) + sb.WriteString("\n") + } + countStr := fmt.Sprintf(" %d", len(rows)) + if s.total != len(rows) { + countStr = fmt.Sprintf(" %d of %d", len(rows), s.total) + } + s.filter.SetWidth(layout.Clamp(rowW-len(countStr)-4, 12, 60)) + sb.WriteString(layout.FitANSIWidth(s.filter.View()+ctx.Hint(countStr), rowW)) + sb.WriteString("\n") + dir := "↑" + if s.sortDesc { + dir = "↓" + } + legend := ctx.InfoStyle.Render("call") + ctx.Hint(" · ") + + ctx.WarnStyle.Render("jump") + ctx.Hint(" · ") + + ctx.AccentStyle.Render("load") + ctx.Hint(" sort: "+s.sort.String()+dir) + sb.WriteString(layout.FitANSIWidth(legend, rowW)) + sb.WriteString("\n\n") + s.listRow = 1 + len(targetRows) + 2 + 1 // title + target line(s) + filter + legend + blank + s.top = layout.VisualTop(s.sel, s.top, len(rows), visible, func(int) int { return 1 }) + end := min(s.top+visible, len(rows)) + for i := s.top; i < end; i++ { + h := s.results[rows[i]] + loc := h.Sym + if loc == "" { + loc = "—" + } + line := fmt.Sprintf(" 0x%0*x %s %s", + addrW, h.Addr, + layout.PadVisual(layout.TruncateMiddle(loc, symW), symW), + kindStyle(ctx, h.Text).Render(layout.TruncateMiddle(h.Text, textW))) + line = layout.PadRight(line, rowW) + if i == s.sel { + line = ctx.SelStyle.Render(ansi.Strip(line)) + } + sb.WriteString(line) + sb.WriteString("\n") + } + + sb.WriteString("\n") + footer := fmt.Sprintf("↑/↓ select · ↵ jump · s/r sort · / filter · Esc close (%d/%d)", + min(s.sel+1, len(rows)), len(rows)) + if s.filtering { + footer = fmt.Sprintf("type to filter · ↵ jump · Tab done · Esc clear (%d/%d)", + min(s.sel+1, len(rows)), len(rows)) + } + sb.WriteString(ctx.Hint(layout.FitANSIWidth(footer, rowW))) + return ctx.Frame(sb.String()) +} diff --git a/internal/ui/modals/xref/xref_test.go b/internal/ui/modals/xref/xref_test.go new file mode 100644 index 0000000..96af2cc --- /dev/null +++ b/internal/ui/modals/xref/xref_test.go @@ -0,0 +1,274 @@ +package xref + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/modal" +) + +type fakeHost struct { + loaded []uint64 + statuses []string +} + +func (h *fakeHost) SetStatus(msg string, isErr bool) { h.statuses = append(h.statuses, msg) } +func (h *fakeHost) LoadDisasmAt(addr uint64) { h.loaded = append(h.loaded, addr) } + +// testCtx needs a real (empty) File: Render asks it for the address column width. +func testCtx() modal.Context { + id := func(s string) string { return s } + return modal.Context{ + File: &binfile.File{}, + Width: 100, + Height: 30, + Styles: &modal.Styles{Title: id, Frame: id, Hint: id}, + } +} + +// hits spans the three sortable dimensions: address order, symbol order, and +// instruction kind — deliberately disagreeing so a sort key can be told apart. +func hits() []Hit { + return []Hit{ + {Addr: 0x3000, Text: "lea rax, [rip+0x10]", Sym: "alpha"}, // kind 2 (load) + {Addr: 0x1000, Text: "call 0x9000", Sym: "zeta"}, // kind 0 (call) + {Addr: 0x2000, Text: "jmp 0x9000", Sym: "mid"}, // kind 1 (jump) + } +} + +func open(t *testing.T) *State { + t.Helper() + s := &State{} + in := textinput.New() + in.Prompt = "/ " + s.SetInput(in) + s.Open("target", hits()) + return s +} + +func shownAddrs(s *State) []uint64 { + out := make([]uint64, 0, len(s.shown)) + for _, i := range s.shown { + out = append(out, s.results[i].Addr) + } + return out +} + +func eq(a []uint64, b ...uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestOpenSortsByAddressAndActivates(t *testing.T) { + s := open(t) + if !s.Active() || s.Sel() != 0 { + t.Fatalf("Open: active=%v sel=%d", s.Active(), s.Sel()) + } + if got := shownAddrs(s); !eq(got, 0x1000, 0x2000, 0x3000) { + t.Errorf("default order = %#x, want ascending address", got) + } +} + +func TestSortCyclesAddressLocationKind(t *testing.T) { + s := open(t) + host := &fakeHost{} + + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "s") // → location + if got := shownAddrs(s); !eq(got, 0x3000, 0x2000, 0x1000) { + t.Errorf("by location = %#x, want alpha/mid/zeta", got) + } + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "s") // → kind + if got := shownAddrs(s); !eq(got, 0x1000, 0x2000, 0x3000) { + t.Errorf("by kind = %#x, want call/jump/load", got) + } + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "s") // → back to address + if got := shownAddrs(s); !eq(got, 0x1000, 0x2000, 0x3000) { + t.Errorf("wrapped back to address = %#x", got) + } + // Each sort change reports the new key on the status line. + if len(host.statuses) != 3 || !strings.Contains(host.statuses[0], "location") { + t.Errorf("statuses = %v", host.statuses) + } +} + +func TestReverseSort(t *testing.T) { + s := open(t) + s.Update(&fakeHost{}, tea.KeyMsg(tea.KeyPressMsg{}), "r") + if got := shownAddrs(s); !eq(got, 0x3000, 0x2000, 0x1000) { + t.Errorf("reversed = %#x, want descending address", got) + } +} + +// TestFilterMatchesSymbolTextAndAddress: the filter box searches all three, so +// "0x2000" finds a row by address and "call" finds one by instruction text. +func TestFilterMatchesSymbolTextAndAddress(t *testing.T) { + for _, tc := range []struct { + needle string + want []uint64 + }{ + {"zeta", []uint64{0x1000}}, // symbol + {"jmp", []uint64{0x2000}}, // instruction text + {"0x3000", []uint64{0x3000}}, // address + {"ZETA", []uint64{0x1000}}, // case-insensitive + {"nothing", nil}, + } { + t.Run(tc.needle, func(t *testing.T) { + s := open(t) + s.filter.SetValue(tc.needle) + s.rebuild() + if got := shownAddrs(s); !eq(got, tc.want...) { + t.Errorf("filter %q = %#x, want %#x", tc.needle, got, tc.want) + } + if s.total != 3 { + t.Errorf("total = %d, want 3 (the pre-filter count)", s.total) + } + }) + } +} + +// TestFilterClampsSelection: filtering to fewer rows than the cursor index must +// not leave the selection past the end. +func TestFilterClampsSelection(t *testing.T) { + s := open(t) + s.sel = 2 + s.filter.SetValue("zeta") + s.rebuild() + if s.Sel() != 0 { + t.Errorf("selection = %d after filtering to one row, want 0", s.Sel()) + } +} + +func TestActivateJumpsToTheSelectedReference(t *testing.T) { + s := open(t) + host := &fakeHost{} + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "down") + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "enter") + if len(host.loaded) != 1 || host.loaded[0] != 0x2000 { + t.Errorf("jumped to %#x, want [0x2000]", host.loaded) + } + if s.Active() { + t.Error("Enter did not close the overlay") + } +} + +// TestActivateOnEmptyResultsIsInert guards the index bounds when a filter has +// hidden every row. +func TestActivateOnEmptyResultsIsInert(t *testing.T) { + s := open(t) + s.filter.SetValue("nothing") + s.rebuild() + host := &fakeHost{} + s.Activate(host) + if len(host.loaded) != 0 { + t.Errorf("jumped with no visible rows: %#x", host.loaded) + } +} + +func TestEscClosesButEscInFilterOnlyClearsIt(t *testing.T) { + s := open(t) + host := &fakeHost{} + + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "/") + if !s.Filtering() { + t.Fatal("/ did not focus the filter") + } + s.filter.SetValue("zeta") + s.rebuild() + + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "esc") + if s.Filtering() { + t.Error("esc did not leave the filter") + } + if !s.Active() { + t.Error("esc in the filter closed the whole overlay") + } + if got := shownAddrs(s); len(got) != 3 { + t.Errorf("esc did not clear the filter: %#x", got) + } + + // A second esc, now outside the filter, closes. + s.Update(host, tea.KeyMsg(tea.KeyPressMsg{}), "esc") + if s.Active() { + t.Error("esc outside the filter did not close the overlay") + } +} + +func TestKindOf(t *testing.T) { + for _, tc := range []struct { + text string + want int + }{ + {"call 0x1000", 0}, + {"callq 0x1000", 0}, + {"bl #0x20", 0}, + {"jmp 0x1000", 1}, + {"je 0x1000", 1}, + {"b 0x1000", 1}, + {"b.eq 0x1000", 1}, + {"lea rax, [rip+8]", 2}, + {"adrp x0, #0x1000", 2}, + {"mov rax, 1", 3}, + } { + if got := kindOf(tc.text); got != tc.want { + t.Errorf("kindOf(%q) = %d, want %d", tc.text, got, tc.want) + } + } +} + +// TestRelabelSymbolsResorts: the demangle toggle rewrites symbol names, and the +// location sort depends on them, so the rows must be rebuilt not just repainted. +func TestRelabelSymbolsResorts(t *testing.T) { + s := open(t) + s.Update(&fakeHost{}, tea.KeyMsg(tea.KeyPressMsg{}), "s") // sort by location + if got := shownAddrs(s); !eq(got, 0x3000, 0x2000, 0x1000) { + t.Fatalf("by location = %#x", got) + } + // Reverse the names: what was "alpha" becomes "zzz". + s.RelabelSymbols(func(addr uint64) string { + switch addr { + case 0x3000: + return "zzz" + case 0x1000: + return "aaa" + } + return "mmm" + }) + if got := shownAddrs(s); !eq(got, 0x1000, 0x2000, 0x3000) { + t.Errorf("after relabel = %#x, want re-sorted by the new names", got) + } +} + +func TestClickRowHonoursScrollTop(t *testing.T) { + s := open(t) + s.top = 1 + if !s.ClickRow(0) || s.Sel() != 1 { + t.Errorf("ClickRow(0) with top=1 selected %d, want 1", s.Sel()) + } + if s.ClickRow(5) { + t.Error("ClickRow past the end reported a hit") + } +} + +func TestRenderShowsTargetLegendAndRows(t *testing.T) { + s := open(t) + out := s.Render(testCtx()) + for _, want := range []string{"Cross-references", "target", "call", "jump", "load", "sort: address", "zeta", "0x0000000000001000"} { + if !strings.Contains(out, want) { + t.Errorf("rendered overlay is missing %q", want) + } + } + if s.ListRow() < 4 { + t.Errorf("ListRow = %d, want at least title+target+filter+legend", s.ListRow()) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index eece673..5f1b391 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -3,15 +3,34 @@ package ui import ( "time" - "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" - "charm.land/lipgloss/v2" - "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/config" "github.com/rabarbra/exex/internal/disasm" "github.com/rabarbra/exex/internal/explorer" "github.com/rabarbra/exex/internal/syntax" + "github.com/rabarbra/exex/internal/ui/modal" + cpufeatmodal "github.com/rabarbra/exex/internal/ui/modals/cpufeat" + findquerymodal "github.com/rabarbra/exex/internal/ui/modals/findquery" + findresultsmodal "github.com/rabarbra/exex/internal/ui/modals/findresults" + findtomodal "github.com/rabarbra/exex/internal/ui/modals/findto" + helpmodal "github.com/rabarbra/exex/internal/ui/modals/help" + jumptomodal "github.com/rabarbra/exex/internal/ui/modals/jumpto" + palettemodal "github.com/rabarbra/exex/internal/ui/modals/palette" + rawheadermodal "github.com/rabarbra/exex/internal/ui/modals/rawheader" + searchmodal "github.com/rabarbra/exex/internal/ui/modals/search" + settingsmodal "github.com/rabarbra/exex/internal/ui/modals/settings" + syscallsmodal "github.com/rabarbra/exex/internal/ui/modals/syscalls" + xrefmodal "github.com/rabarbra/exex/internal/ui/modals/xref" + "github.com/rabarbra/exex/internal/ui/view" + disasmview "github.com/rabarbra/exex/internal/ui/views/disasm" + "github.com/rabarbra/exex/internal/ui/views/hexraw" + infoview "github.com/rabarbra/exex/internal/ui/views/info" + "github.com/rabarbra/exex/internal/ui/views/libs" + "github.com/rabarbra/exex/internal/ui/views/relocs" + "github.com/rabarbra/exex/internal/ui/views/sections" + "github.com/rabarbra/exex/internal/ui/views/sources" + "github.com/rabarbra/exex/internal/ui/views/strs" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) // mode identifies the active top-level view. @@ -54,6 +73,8 @@ func (m mode) String() string { return "Strings" case modeSources: return "Sources" + case modeRelocs: + return "Relocs" } return "?" } @@ -61,117 +82,6 @@ func (m mode) String() string { // layoutState tracks terminal dimensions and the header viewport. type layoutState struct { width, height int - headerVP viewport.Model -} - -// sectionsState stores list/filter state for the Sections view, which toggles -// between the section table and the coarser segment (memory-region) table. -type sectionsState struct { - sections []binfile.Section - segments []binfile.Segment - showSegments bool // the `t` toggle: list segments instead of sections - sectionsFilter textinput.Model - sectionsFiltered []int // indices into the active slice (sections or segments) - sectionsCur int - sectionsTop int - sectionsSort sectionSort // sort field for the (filtered) list - sectionsSortDesc bool // reverse the active sort - sectionsTypeOn bool // type-name column filter active - sectionsType string // the type name it restricts to - sectionsTypes []string // distinct type names, for cycling - sectionsFlagsOn bool // flags column filter active - sectionsFlags string // the flag string it restricts to - sectionsFlagsList []string // distinct flag strings, for cycling - sectionRowCache rowMemo[rowCacheKey, string] - sectionHeightCache rowMemo[rowCacheKey, int] -} - -// rowCacheKey identifies a rendered table-row variant. The Sections, Symbols -// and Strings views all cache rows by the same coordinates: the item index plus -// every layout input that changes how a row renders. -type rowCacheKey struct { - i int - width int - addrW int - wrap bool -} - -// rowMemo is a lazily-allocated memo cache for rendered rows (or their heights), -// keyed by K. It centralises the "nil-check → lookup → build → store" pattern the -// list/disasm renderers repeated by hand, so a renderer can't forget to allocate -// or to populate the cache, and invalidation is a single clear(). The zero value -// (nil map) is ready to use. -type rowMemo[K comparable, V any] map[K]V - -// get returns the cached value for key, building and caching it on a miss. -func (m *rowMemo[K, V]) get(key K, build func() V) V { - if *m == nil { - *m = make(rowMemo[K, V]) - } else if v, ok := (*m)[key]; ok { - return v - } - v := build() - (*m)[key] = v - return v -} - -// clear drops all cached entries (next get rebuilds). -func (m *rowMemo[K, V]) clear() { *m = nil } - -// symbolsState stores list/filter state for the Symbols view. -type symbolsState struct { - symbolsFilter textinput.Model - symbolsFiltered []int // indices into file.Symbols (sorted by name) - symbolsCur int - symbolsTop int - symbolsKind binfile.SymKind - symbolsKindOn bool - symbolsBind binfile.SymBind - symbolsBindOn bool - symbolsScope symbolScope // all / internal (defined here) / imported (from libs) - symbolsSort symbolSort // view order: name / address / size - symbolsSortDesc bool // reverse the active sort (descending) - symbolsLib string // when set, show only imports bound to this library - symbolsTree bool // group names into a collapsible namespace tree (name sort) - symbolsAbbrev bool // global: render "(…)"/"<…>" contents as "..." - symbolsAbbrevExcept map[string]bool // per-row overrides inverting symbolsAbbrev - symbolsCollapsed map[string]bool // collapsed tree node paths (persist across rebuilds) - symbolsCollapsedAlt map[string]bool // pre-filter collapse state, saved while a search filter is active - symbolsFiltering bool // whether a search filter is currently narrowing the tree - symbolsRoots []*treeNode // built tree; cached so collapse toggles only re-flatten - symbolsRows []treeRow // flattened visible rows (tree nodes + leaves), nav/render unit - symbolsReady bool // rows/tree have been built at least once - symbolsTreeInit bool // collapse-default applied once - symbolsByDisplay []int // all symbol indices sorted by Display(); built lazily - demangledNames []string // cached ComputeDemangled result, for the live demangle toggle - symbolFacets []facetHit // clickable toggle buttons on the status line (x ranges) - symbolRowCache rowMemo[rowCacheKey, []string] - symbolHeightCache rowMemo[rowCacheKey, int] -} - -// facetKind identifies a clickable toggle button on the symbols status line. -type facetKind int - -const ( - facetType facetKind = iota - facetScope - facetSort - facetSortDir - facetBind - facetTree - facetAbbrev -) - -// facetHit is the screen-column span [start,end) of one clickable toggle button. -type facetHit struct { - start, end int - kind facetKind -} - -// clearSymbolCaches drops cached symbol rows and heights. -func (m *Model) clearSymbolCaches() { - m.symbolRowCache = nil - m.symbolHeightCache = nil } // clearSymbolNameCaches drops caches whose layout depends on how symbol names @@ -181,219 +91,59 @@ func (m *Model) clearSymbolCaches() { // toggle. (Hex/raw and the disasm annotations themselves render live; the asm // colour cache is keyed by raw instruction text, which carries no symbol name.) func (m *Model) clearSymbolNameCaches() { - m.disasmHeightCache = nil -} - -// clearSectionCaches drops cached section rows and heights. -func (m *Model) clearSectionCaches() { - m.sectionRowCache = nil - m.sectionHeightCache = nil -} - -// clearStringCaches drops cached string rows and heights. -func (m *Model) clearStringCaches() { - m.stringRowCache = nil - m.stringHeightCache = nil + m.dasm.HeightCache = nil + m.dasm.AnnCache = nil // annotations name their target symbol + m.byteViews.DropCaches() // hex rows trail the symbol covering the row + m.relocs.DropCaches() // reloc bind targets render demangled/abbreviated names } // clearAllViewCaches drops all row caches affected by global layout toggles. func (m *Model) clearAllViewCaches() { - m.clearSymbolCaches() - m.clearSectionCaches() - m.clearStringCaches() - m.srcLineHeightCache = nil + m.symbols.DropCaches() + m.sections.DropCaches() + m.strs.DropCaches() + m.dasm.SrcLineHeightCache = nil } // clearColorCaches drops every cache whose entries bake in theme colours, so a // theme change is reflected on the next render. (Height/column caches depend only // on geometry, not colour, so they're left intact.) func (m *Model) clearColorCaches() { + m.viewStylesCache = nil + m.footerGlobals = "" + m.footerCache = "" + m.tabsCache = "" + m.modalStylesCache = nil m.clearAllViewCaches() - m.disasmAsmCache = nil - m.disasmTokenStyles = nil - m.sourceAsmRowCache = nil - m.relocRowCache = nil - m.infoBody = "" // restyle the Info page on the next render -} - -// disasmState holds the currently loaded decode window only. The first window -// is loaded lazily on first open; later jumps replace it with a bounded span -// around the requested address so large binaries never expand into a whole-image -// instruction slice. + m.dasm.AsmCache = nil + m.dasm.AsmHL = nil + m.dasm.SourceAsmRowCache = nil + m.byteViews.DropCaches() // hex/raw rows bake in the byte palette + m.relocs.DropCaches() + m.info.DropCaches() // restyle the Info page on the next render +} + +// disasmState holds the shell's side of the disassembly view: the decode +// engine and its budget/strategy settings. The view's own state — the decoded +// window, cursor, history, source split pane, render caches — lives in +// views/disasm.State (the dasm field). type disasmState struct { - disasmInst []disasm.Inst - disasmBuilt bool - disasmDecoding bool // background decode in flight + dasm disasmview.State disasmMaxBytes int disasmSearchWorkers int - disasmPendingAddr uint64 disasmInitAddr uint64 disasmTarget string // configured landing/redirect strategy - disasmPositioned bool - disasmCur int - disasmTop int - disasmPosLo int - disasmPosHi int disasmSvc *explorer.DisasmService - showSource bool - sourceFirst bool - rightScroll int // extra scroll offset for the follower (right) pane; 0 = auto-follow - srcVP viewport.Model srcHighlighter *syntax.Highlighter - sourceAsmRowCache rowMemo[sourceAsmRowCacheKey, string] - disasmAsmCache rowMemo[disasmAsmCacheKey, string] - // disasmTokenStyles caches Chroma token-type → style (default build only); it - // is keyed by int(chroma.TokenType) so the model stays chroma-free for `lite`. - disasmTokenStyles map[int]lipgloss.Style - // disasmHeightCache memoizes per-instruction rendered height (it otherwise - // re-renders each instruction to count rows, which the scroll math calls - // dozens of times per wheel tick). Reset whenever disasmInst is replaced. - disasmHeightCache rowMemo[disasmHeightKey, int] - // execSecStarts maps each executable section's start address to its name, so - // the disasm scroller's per-row section-separator check is an O(1) lookup - // instead of a scan over all sections. Built once (sections are immutable). - execSecStarts map[uint64]string -} - -// disasmHeightKey identifies a cached instruction height for one layout. -type disasmHeightKey struct { - i int - w int - wrap bool } -// sourceAsmRowCacheKey identifies a cached source/assembly mapping row. -type sourceAsmRowCacheKey struct { - i int - w int - file string - line int -} - -// disasmAsmCacheKey identifies one highlighted instruction/comment string. -type disasmAsmCacheKey struct { - text string - addr uint64 - cls disasm.InstClass -} - -// historyState stores disassembly navigation history. -type historyState struct { - // Last `historyCap` disasm jump targets. historyPos indicates where in that - // ring we are; left arrow steps back, right arrow steps forward. - history []uint64 - historyPos int -} - -// hexState stores cursor and viewport state for the mapped hex view. -type hexState struct { - hexImg *binfile.Image - hexCur int // byte position into hexImg.Data - hexTop int // first row's byte position - hexPinnedTop int // section start pinned by jump/search until wheel scroll - hexPinned bool -} - -// rawState stores cursor and viewport state for the raw file view. -type rawState struct { - rawData []byte - rawCur int - rawTop int - rawPinnedTop int // section start pinned by jump/search until wheel scroll - rawPinned bool - // rawSecByOff is the file-backed sections sorted by file offset, so the raw - // view's per-row section lookups binary-search instead of scanning every - // section. Built once (sections are immutable). - rawSecByOff []*binfile.Section -} - -// libsState stores cursor and viewport state for the Libraries view. -type libsState struct { - libsCur int - libsTop int - libsTree bool // show needed libraries as a path tree - libsCollapsed map[string]bool // collapsed directory paths - libsRows []treeRow // flattened visible rows (dirs + libs) - libsTreeInit bool - libsAvail availFilter // availability filter: all / on-disk / in cache - libsAvailKind map[string]availKind - libsFilter textinput.Model // name search (the `/` filter) - libsSortDesc bool // reverse the (name) sort -} - -// relocsState stores cursor, filter and cache state for the Relocations view. -type relocsState struct { - relocCur int // cursor in the relocation table - relocTop int // viewport top of the relocation table - relocFilter textinput.Model // symbol/type/section search (the `/` filter) - relocFiltered []int // indices into file.Relocations() after the filter - relocSort relocSortField // sort field for the relocation table - relocSortDesc bool // reverse the relocation sort - relocTypeOn bool // type-name facet filter active - relocType string // the relocation type it restricts to - relocTypes []string // distinct types, for cycling - relocSecOn bool // section facet filter active - relocSec string // the section it restricts to - relocSecs []string // distinct sections, for cycling - relocRowCache rowMemo[rowCacheKey, string] -} - -// stringsState stores list, filter and cache state for printable strings. -type stringsState struct { - stringsList []binfile.StringEntry - stringsFilter textinput.Model - stringsFiltered []int // indices into stringsList - stringsCur int // index into stringsFiltered - stringsTop int - stringsSections []string // distinct owning-section names, for the section filter - stringsSecOn bool // section filter active - stringsSec string // the section name the filter restricts to - stringsSort stringSort // sort field for the (filtered) list - stringsSortDesc bool // reverse the active sort - stringsCompact bool // flow strings inline (· separated, no columns) vs the table - stringsPathsOnly bool // show only path-like strings (filesystem paths / URLs) - stringRowCache rowMemo[rowCacheKey, string] - stringHeightCache rowMemo[rowCacheKey, int] -} - -// sourcesState stores file-list and open-file state for the Sources view. -type sourcesState struct { - sourcesFiles []string - sourcesFilter textinput.Model - sourcesFiltered []int - sourcesCur int - sourcesTop int - sourcesTree bool // show the file list as a directory tree - sourcesAvail availFilter // availability filter: all / present / missing - sourcesSort sourceSort // flat-list order: project-first or name - sourcesSortDesc bool // reverse the active sort - sourcesCollapsed map[string]bool // collapsed directory paths - sourcesRows []treeRow // flattened visible rows (dirs + files) - sourcesTreeInit bool - srcFile string // open source file ("" = showing the file list) - srcCur int // 1-based current line in the open file - srcTop int - srcCodeLines map[int]bool // lines in srcFile that have machine code - srcCodeLineCache map[string]map[int]bool - srcColumnCache map[sourceLineCacheKey][]int - srcLineHeightCache map[sourceLineHeightKey]int - srcMatches []srcMatch // last cross-source grep - srcMatchIdx int - srcSearchAll bool // scope of the next search in this view -} - -// sourceLineCacheKey identifies cached line-column metadata. -type sourceLineCacheKey struct { - file string - line int -} - -// sourceLineHeightKey identifies a cached wrapped source-line height. Source -// content is immutable once loaded, so width is the only layout input. -type sourceLineHeightKey struct { - file string - line int - w int +// sourcePaneState stores the shell's search state for the source pane: the +// cross-source grep results and the scope of the next search. The pane itself +// (open file, cursors, caches) lives in views/disasm.SourceState. +type sourcePaneState struct { + srcMatches []srcMatch // last cross-source grep + srcMatchIdx int + srcSearchAll bool // scope of the next search in this view } // interactionState stores cross-view input and viewport state. @@ -406,18 +156,6 @@ type interactionState struct { // it is built (config behavior.tree_collapsed). treeCollapseDefault bool - // hexNumeric switches the hex/raw views' trailing column from ASCII (default) - // to the numeric interpretation selected by hexInterp. `t` toggles ascii vs - // numeric; shift+t cycles the interpretation. The pointer-sized hex - // interpretation resolves each word to the symbol/section it points at. - hexNumeric bool - hexInterp int // index into hexInterps; -1 until initialised to the pointer width - - // hexInspect replaces the hex/raw banner with a data inspector that decodes - // the bytes under the cursor as int/uint of every width, float, char and - // pointer (the `i` key). It updates live as the cursor moves. - hexInspect bool - // Mouse double-click tracking (for follow-on-double-click in disasm). lastClickY int lastClickAt time.Time @@ -441,39 +179,16 @@ type interactionState struct { pendingKeyN int keyTicking bool - // Last top row/offset actually rendered for each scrollable view. Wheel input - // starts from these screen snapshots so queued key/mouse events cannot snap - // the first wheel step back to the caret-derived top. - renderedSectionsTop int - renderedSymbolsTop int - renderedDisasmTop int - renderedHexTop int - renderedRawTop int - renderedStringsTop int - renderedSourcesTop int - renderedLibsTop int - renderedSrcTop int + // Last top row/offset actually rendered for shell-owned scrollable views. Wheel + // input starts from these screen snapshots so queued key/mouse events cannot + // snap the first wheel step back to the caret-derived top. + renderedDisasmTop int // pageRows is the active view's page size (items per screen) recorded at the // last render, so pgup/pgdown ([ / ]) advance by exactly one screen instead // of overshooting on chrome rows or wrapped multi-line entries. pageRows int - // helpActive toggles the keybinding cheat-sheet overlay; helpScroll is its - // vertical scroll offset when it is taller than the terminal. - helpActive bool - helpScroll int - - // headerActive toggles the raw container-header overlay (ELF e_*, Mach-O - // mach_header + load commands, PE headers); headerScroll is its scroll offset. - headerActive bool - headerScroll int - - // infoBody caches the Info view's styled body (static per width/theme/arch); - // infoBodyW is the width it was built for. Cleared on a theme change. - infoBody string - infoBodyW int - // View output memoization. Bubble Tea calls View() after every message, so a // burst of wheel events that only accumulate (without changing what's shown) // would each recompute the whole screen. viewDirty defaults to true every @@ -497,41 +212,49 @@ func (m *Model) setMode(md mode) { // gotoState stores modal state for address/symbol navigation. type gotoState struct { - gotoInput textinput.Model - gotoActive bool - gotoResults []gotoTarget - gotoSel int - gotoTop int // scroll offset into gotoResults - gotoScope gotoScope // what the palette searches (all / symbols / sections / …) - gotoAddrPhys bool // interpret a typed address as physical (LMA), resolving to virtual +} + +// jumpState stores what the shell keeps for the "open caret position in another +// view" overlay: the caret it was opened for. The overlay's own state (rows, +// selection) lives on m.jump (internal/ui/modals/jumpto). +type jumpState struct { + jumpCaret caret +} + +// findState stores the "Find from here" flow: a seed picker (candidate searches +// derived from the caret) feeding a global value search whose results — disasm +// operand references, data-word occurrences, string matches and reloc targets — +// are listed in one modal, tagged and filterable by the view they belong to. +// findState holds what the shell keeps for the global value search: the async +// bookkeeping for the per-source scans. The two overlays it drives — the +// free-text prompt and the results list — live in internal/ui/modals/findquery +// and internal/ui/modals/findresults. +type findState struct { + findSeq int + findCancel chan struct{} + // findQueryCase mirrors the prompt's case-sensitivity toggle, so a query typed + // there is interpreted the same way the prompt showed it. + findQueryCase bool } // searchState stores modal and async state for view searches. +// searchState holds what the shell keeps for the in-view search: the last query +// (so n/N can repeat it) and the async disasm-scan bookkeeping. The prompt and +// its toggles live on m.search (internal/ui/modals/search). type searchState struct { - searchInput textinput.Model - searchActive bool searchQuery string searchSeq int searchCancel chan struct{} searchRunning bool searchCancelable bool - searchResults disasmSearchCache - searchCursorMode int - searchMode searchMode + searchResults explorer.SearchCache + searchCursorMode explorer.CursorMode searchCursorAddr uint64 - searchForward bool - searchFromCursor bool } -// settingsState stores state for the on-the-fly settings popup. +// settingsState holds the overlay geometry the shell still tracks. The settings +// popup's own state (selection, scroll, row→field map) lives on m.settings. type settingsState struct { - settingsActive bool - settingsCur int // selected field index (0..settingsFieldCount-1) - settingsTop int // first visible field when the list is taller than the window - // settingsLineFields maps each rendered list line (from modalListRow) to its - // field index, or -1 for a group header / blank separator. Rebuilt every render - // so a mouse click lands on the right field despite the interspersed headers. - settingsLineFields []int // modalListRow is the content row (within whichever overlay modal is open) // where its scrollable list/fields begin, set by that modal's render so a mouse // click can be mapped to an item. Only one modal is open at a time. @@ -540,6 +263,21 @@ type settingsState struct { // statusState stores the footer status message. type statusState struct { + // The memoised footer (see renderFooter): the finished bar plus the inputs it + // was built from. Dropped by clearColorCaches on a theme change. + footerGlobals string // the styled "│ ? help · q quit" tail + footerCache string // the whole bar + footerHints []footerHint + footerWidth int + footerStatus string + footerStatusErr bool + + // The memoised tab strip (see renderTabs), with the inputs it was built from. + tabsCache string + tabsMode mode + tabsWidth int + tabsCrumb string + status string statusError bool lastCopy string // last text sent to the clipboard (test seam; see copyToClipboard) @@ -566,29 +304,69 @@ type Model struct { // dependency / archive member / fat-arch slice each replace the whole model), // so Back can return to them with their state intact; fileLabel is this file's // breadcrumb name. - fileStack []*Model - fileLabel string + fileStack []*Model + fileLabel string + background backgroundState mode mode + // viewStylesCache is the lazily-built style/closure vocabulary shared with + // the view packages (see viewStyles). Dropped on theme or settings changes. + viewStylesCache *view.Styles + // modalStylesCache is the same for the modal packages (see modalStyles). + modalStylesCache *modal.Styles + layoutState - sectionsState - symbolsState + info infoview.State + sections sections.State + symbols symbols.State disasmState - historyState - hexState - rawState - libsState - relocsState - stringsState - sourcesState + + // demangledNames caches the background ComputeDemangled result so the + // demangle setting can be toggled live without recomputing. + demangledNames []string + byteViews hexraw.State + libs libs.State + relocs relocs.State + strs strs.State + sources sources.State + sourcePaneState interactionState gotoState + jumpState + findState searchState settingsState xrefState syscallState cpufeatState + // cpufeat is the CPU-features overlay (internal/ui/modals/cpufeat). Modals are + // migrating to their own packages behind modal.Modal; cpufeatState above keeps + // only the async bookkeeping for its background scan. + cpufeat cpufeatmodal.State + // settings is the preferences overlay (internal/ui/modals/settings); what a + // change *means* stays in the shell (see settings.go). + settings settingsmodal.State + // jump is the "open caret position in…" overlay (internal/ui/modals/jumpto). + jump jumptomodal.State + // find is the "Find from here" seed picker (internal/ui/modals/findto). + find findtomodal.State + // palette is the "Jump to" command palette (internal/ui/modals/palette). + palette palettemodal.State + // findQuery is the free-text search prompt (internal/ui/modals/findquery). + findQueryModal findquerymodal.State + // findResults is the global-search results overlay (internal/ui/modals/findresults). + findResults findresultsmodal.State + // help is the keybinding cheat-sheet overlay (internal/ui/modals/help). + help helpmodal.State + // header is the raw container-header overlay (internal/ui/modals/rawheader). + header rawheadermodal.State + // search is the in-view search prompt (internal/ui/modals/search). + search searchmodal.State + // syscalls is the system-calls results overlay (internal/ui/modals/syscalls). + syscalls syscallsmodal.State + // xref is the cross-references results overlay (internal/ui/modals/xref). + xref xrefmodal.State archiveState statusState keyState diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index df464d4..6edf9ed 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -10,6 +10,12 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/modal" + searchmodal "github.com/rabarbra/exex/internal/ui/modals/search" + "github.com/rabarbra/exex/internal/ui/modals/textoverlay" + "github.com/rabarbra/exex/internal/ui/views/hexraw" ) // doubleClickWindow is how close two clicks must be (in time, on the same row) @@ -24,6 +30,9 @@ const wheelQuietInterval = 120 * time.Millisecond // all other input (clicks, keys) until it drained. const wheelCoalesceInterval = 16 * time.Millisecond +// wheelScrollLines is how many lines one wheel notch moves a scrollable surface. +const wheelScrollLines = 3 + // wheelTickMsg fires after wheelCoalesceInterval to apply any accumulated scroll. type wheelTickMsg struct{} @@ -33,14 +42,43 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if shift && ms.Button == tea.MouseLeft { return m, nil } - if _, ok := msg.(tea.MouseClickMsg); ok && m.searchActive && ms.Button == tea.MouseLeft { - m.handleSearchPopupClick(ms.X, ms.Y) + // An open overlay owns the mouse. Anything that reaches past this switch is + // aimed at the view, so every overlay must either consume the event or + // deliberately let it through — help and header used to be absent here + // entirely, which let clicks switch tabs and the wheel scroll the view behind + // an overlay that covered it. + switch kind := m.activeModal(); kind { + case modalNone: + // Fall through to the view handling below. + + case modalSearch: + // The search prompt is a thin popup, not a full overlay: clicks target its + // mode switches, but the wheel deliberately still scrolls the view behind + // it so results can be scanned without dismissing the prompt. + if _, ok := msg.(tea.MouseClickMsg); ok && ms.Button == tea.MouseLeft { + m.handleSearchPopupClick(ms.X, ms.Y) + return m, nil + } + + case modalHeader, modalHelp: + // Scrollable text overlays with no selection: the wheel pages them (as the + // arrow keys do), and everything else is swallowed. + if _, ok := msg.(tea.MouseWheelMsg); ok { + delta := wheelScrollLines + if ms.Button == tea.MouseWheelUp { + delta = -wheelScrollLines + } + // Both overlays are textoverlay.Scrollers; the offset is clamped where + // they render, since the row count depends on the terminal size. + m.scrollableOverlay(kind).Scroll(delta) + } return m, nil - } - // A list/field overlay modal (xref, goto, settings) captures the mouse so it - // drives the modal, not the view behind it: wheel moves the selection, a click - // selects an item, a double-click activates it. - if m.modalActive() { + + default: + // List/field overlays capture the mouse so it drives the modal, not the + // view behind it: wheel moves the selection, a click selects an item, a + // double-click activates it. Overlays with no list (findQuery) simply + // swallow the event. if _, ok := msg.(tea.MouseWheelMsg); ok { switch ms.Button { case tea.MouseWheelUp: @@ -54,12 +92,13 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { now := time.Now() isDouble := ms.Y == m.lastClickY && now.Sub(m.lastClickAt) < doubleClickWindow m.lastClickY, m.lastClickAt = ms.Y, now - if m.modalClick(ms.X, ms.Y) && isDouble { + if m.modalClick(ms.Y) && isDouble { return m.modalActivate() } } return m, nil } + if _, ok := msg.(tea.MouseWheelMsg); ok { switch ms.Button { case tea.MouseWheelUp: @@ -93,7 +132,12 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { suppressDouble := m.handleClick(ms.X, ms.Y) if before != m.activeCursorState() { m.viewportDetached = false - m.pinCurrentByteSectionStart() + switch m.mode { + case modeHex: + m.byteViews.PinCurrentSectionStart(m.viewContextPtr(), hexraw.Hex) + case modeRaw: + m.byteViews.PinCurrentSectionStart(m.viewContextPtr(), hexraw.Raw) + } } if isDouble && !suppressDouble { // Double-clicking a member loads it (the new model must propagate). @@ -106,30 +150,40 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) mouseOverRightPane(x int) bool { - return m.rightPaneActive() && x >= m.width/2 +// scrollableOverlay returns the open text overlay's scroller. Only modalHeader +// and modalHelp reach it. +func (m *Model) scrollableOverlay(kind modalKind) *textoverlay.Scroller { + if kind == modalHeader { + return &m.header.Scroller + } + return &m.help.Scroller } -// modalActive reports whether a list/field overlay modal (not the search prompt, -// which handles its own clicks) is open. -func (m *Model) modalActive() bool { - return m.xrefActive || m.syscallActive || m.cpufeatActive || m.gotoActive || m.settingsActive +func (m *Model) mouseOverRightPane(x int) bool { + return m.rightPaneActive() && x >= m.width/2 } // modalList returns the open modal's selection pointer, rendered scroll top, item -// count and whether the selection wraps (settings cycles). +// count and whether the selection wraps (settings cycles). ok is false for +// overlays with no list — they still capture the mouse, they just ignore it. func (m *Model) modalList() (sel *int, top, n int, wrap, ok bool) { - switch { - case m.xrefActive: - return &m.xrefSel, m.xrefTop, len(m.xrefShown), false, true - case m.syscallActive: - return &m.syscallSel, m.syscallTop, len(m.syscallShown), false, true - case m.cpufeatActive: - return &m.cpufeatSel, m.cpufeatTop, len(m.cpufeatFeats), false, true - case m.gotoActive: - return &m.gotoSel, m.gotoTop, len(m.gotoResults), false, true - case m.settingsActive: - return &m.settingsCur, m.settingsTop, settingsFieldCount, true, true + switch m.activeModal() { + case modalXref: + return m.xref.List() + case modalSyscall: + return m.syscalls.List() + case modalCPUFeat: + return m.cpufeat.List() + case modalGoto: + return m.palette.List() + case modalSettings: + return m.settings.List() + case modalJump: + return m.jump.List() + case modalFind: + return m.find.List() + case modalFindResults: + return m.findResults.List() } return nil, 0, 0, false, false } @@ -143,74 +197,73 @@ func (m *Model) modalScrollSel(d int) { if wrap { *sel = (*sel + d%n + n) % n } else { - *sel = clamp(*sel+d, 0, n-1) + *sel = layout.Clamp(*sel+d, 0, n-1) } } // modalClick maps a click to an item in the open modal's list and selects it, // returning whether it hit one. It re-renders the modal to recompute its centred // geometry and the list's starting row (modalListRow). -func (m *Model) modalClick(x, y int) bool { - var modal string - switch { - case m.xrefActive: - modal = m.renderXrefModal() - case m.syscallActive: - modal = m.renderSyscallModal() - case m.cpufeatActive: - modal = m.renderCPUFeatModal() - case m.gotoActive: - modal = m.renderGotoModal() - case m.settingsActive: - modal = m.renderSettingsModal() - default: +func (m *Model) modalClick(y int) bool { + box := m.renderActiveModal() + if box == "" { return false } - mtop := (m.height - lipgloss.Height(modal)) / 2 + mtop := (m.height - lipgloss.Height(box)) / 2 // content row = y - modalTop - border(1) - padding-top(1), then minus where the // list begins within the modal content. listRow := (y - mtop - 2) - m.modalListRow if listRow < 0 { return false } - // Settings has group headers and separators interleaved, so a rendered line - // maps to a field through settingsLineFields (-1 for non-selectable lines). - if m.settingsActive { - if listRow < len(m.settingsLineFields) { - if f := m.settingsLineFields[listRow]; f >= 0 { - m.settingsCur = f - return true - } - } - return false + // Extracted modals own their row→item mapping: settings interleaves group + // headers and separators, so a rendered line does not correspond to a list + // index the way the flat result lists do. + switch m.activeModal() { + case modalSettings: + return m.settings.ClickRow(listRow) + case modalCPUFeat: + return m.cpufeat.ClickRow(listRow) + case modalJump: + return m.jump.ClickRow(listRow) + case modalFind: + return m.find.ClickRow(listRow) + case modalGoto: + return m.palette.ClickRow(listRow) + case modalXref: + return m.xref.ClickRow(listRow) + case modalFindResults: + return m.findResults.ClickRow(listRow) + case modalSyscall: + return m.syscalls.ClickRow(listRow) } sel, top, n, _, ok := m.modalList() if !ok { return false } - if idx := top + listRow; idx >= 0 && idx < n { - *sel = idx - return true - } - return false + return modal.ClickIndex(sel, top, n, listRow) } // modalActivate runs the open modal's Enter action (mouse double-click). func (m *Model) modalActivate() (tea.Model, tea.Cmd) { - switch { - case m.xrefActive: - return m.xrefJump() - case m.syscallActive: - return m.syscallJump() - case m.cpufeatActive: - return m.cpufeatJump() - case m.gotoActive: - m.activateGoto() - m.closeGoto() - return m, nil - case m.settingsActive: - m.cycleSetting(1) + switch m.activeModal() { + case modalXref: + return m, m.xref.Activate(m) + case modalSyscall: + return m, m.syscalls.Activate(m) + case modalCPUFeat: + return m, m.cpufeat.Activate(m) + case modalGoto: + m.palette.Activate(m) return m, nil + case modalSettings: + return m, m.settings.Activate(m) + case modalJump: + return m, m.jump.Activate(m) + case modalFind: + return m, m.find.Activate(m) + case modalFindResults: + return m, m.findResults.Activate(m) } return m, nil } @@ -289,27 +342,27 @@ func (m *Model) listGeometryFor() (listGeometry, bool) { return listGeometry{len(m.archiveMembers), 2, oneRow, &m.memberSel, &m.memberTop, m.memberTop}, true } case modeSections: - return listGeometry{len(m.sectionsFiltered), 2, m.sectionRowHeight, &m.sectionsCur, &m.sectionsTop, m.renderedSectionsTop}, true + return listGeometry{len(m.sections.Filtered), 2, m.sections.RowHeightFn(m.viewContext()), &m.sections.Cur, &m.sections.Top, m.sections.RenderedTop}, true case modeSymbols: - return listGeometry{len(m.symbolsRows), 2, m.symbolRowHeight, &m.symbolsCur, &m.symbolsTop, m.renderedSymbolsTop}, true + return listGeometry{len(m.symbols.Rows), 2, m.symbols.RowHeightFn(m.viewContext()), &m.symbols.Cur, &m.symbols.Top, m.symbols.RenderedTop}, true case modeStrings: - m.ensureStrings() - if m.stringsCompact { + m.strs.Ensure(m.viewContext()) + if m.strs.Compact { return listGeometry{}, false // the · flow has its own line-based scroll } - return listGeometry{len(m.stringsFiltered), 2, m.stringRowHeight, &m.stringsCur, &m.stringsTop, m.renderedStringsTop}, true + return listGeometry{len(m.strs.Filtered), 2, m.strs.RowHeightFn(m.viewContext()), &m.strs.Cur, &m.strs.Top, m.strs.RenderedTop}, true case modeSources: - m.ensureSources() - return listGeometry{len(m.sourcesRows), 1, oneRow, &m.sourcesCur, &m.sourcesTop, m.renderedSourcesTop}, true + ctx := m.viewContext() + m.sources.Ensure(ctx) + return listGeometry{len(m.sources.Rows), 1, oneRow, &m.sources.Cur, &m.sources.Top, m.sources.RenderedTop}, true case modeRelocs: - m.recomputeRelocs() - return listGeometry{len(m.relocFiltered), 2, oneRow, &m.relocCur, &m.relocTop, m.relocTop}, true + return listGeometry{len(m.relocs.Filtered), 2, oneRow, &m.relocs.Cur, &m.relocs.Top, m.relocs.Top}, true case modeLibs: if m.file.Info == nil { return listGeometry{}, false } - m.buildLibRows() - return listGeometry{len(m.libsRows), m.libsHeaderRows(), m.libRowHeight, &m.libsCur, &m.libsTop, m.renderedLibsTop}, true + ctx := m.viewContext() + return listGeometry{len(m.libs.Rows), m.libs.HeaderRows(ctx), m.libs.RowHeightFn(ctx), &m.libs.Cur, &m.libs.Top, m.libs.RenderedTop}, true } return listGeometry{}, false } @@ -324,167 +377,56 @@ func (m *Model) routeScroll(delta int) (tea.Model, tea.Cmd) { } switch m.mode { case modeStrings: // compact · flow (the table is handled via listGeometry above) - m.scrollStringsFlow(delta) + m.strs.ScrollFlow(m.viewContext(), delta) case modeDisasm: - m.scrollDisasmViewport(delta) + m.dasm.Scroll(m.viewContextPtr(), m.dasmEnv(), delta) case modeHex: - m.ensureHex() - m.clearByteSectionPin(modeHex) - m.hexTop = m.scrollByteViewportTop(modeHex, m.hexImg, m.hexTop, max(1, m.bodyHeight()-1), delta, m.hexImg.AddrAt) + m.byteViews.Scroll(m.viewContextPtr(), hexraw.Hex, delta) case modeRaw: - m.ensureRaw() - m.clearByteSectionPin(modeRaw) - m.rawTop = m.scrollByteViewportTop(modeRaw, rawBytes(m.rawData), m.rawTop, max(1, m.bodyHeight()-1), delta, identityAddr) + m.byteViews.Scroll(m.viewContextPtr(), hexraw.Raw, delta) case modeInfo: - if delta < 0 { - m.headerVP.ScrollUp(-delta) - } else { - m.headerVP.ScrollDown(delta) - } + m.info.Scroll(delta) } return m, nil } func (m *Model) captureViewportTop() { if g, ok := m.listGeometryFor(); ok { - *g.top = viewportTop(g.renderedTop, g.n, g.visible(m.bodyHeight()), g.rowHeight) + *g.top = layout.ViewportTop(g.renderedTop, g.n, g.visible(m.bodyHeight()), g.rowHeight) return } switch m.mode { case modeDisasm: - m.captureDisasmViewportTop() + m.dasm.CaptureViewportTop(m.viewContextPtr()) case modeHex: - m.ensureHex() - m.hexTop = m.scrollByteViewportTop(modeHex, m.hexImg, m.renderedHexTop, max(1, m.bodyHeight()-1), 0, m.hexImg.AddrAt) + m.byteViews.CaptureViewportTop(m.viewContextPtr(), hexraw.Hex) case modeRaw: - m.ensureRaw() - m.rawTop = m.scrollByteViewportTop(modeRaw, rawBytes(m.rawData), m.renderedRawTop, max(1, m.bodyHeight()-1), 0, identityAddr) - } -} - -func (m *Model) captureDisasmViewportTop() { - if m.sourceFirst && m.srcFile != "" { - src := m.file.SourceLines(m.srcFile) - if len(src) == 0 { - return - } - contentH := max(1, m.bodyHeight()-1) - top := viewportTop(m.renderedSrcTop, len(src), contentH, m.sourceRowHeight(m.sourcePaneWidth())) - m.srcTop = top + 1 - return - } - if len(m.disasmInst) == 0 { - return - } - visible := m.disasmViewportHeight() - m.disasmTop = viewportTop(m.renderedDisasmTop, len(m.disasmInst), visible, m.disasmRowHeight(m.disasmRenderWidth())) -} - -func (m *Model) scrollDisasmViewport(delta int) { - if delta == 0 { - return - } - if m.sourceFirst && m.srcFile != "" { - src := m.file.SourceLines(m.srcFile) - if len(src) == 0 { - return - } - contentH := max(1, m.bodyHeight()-1) - top := scrollViewportTop(max(0, m.srcTop-1), len(src), contentH, delta, m.sourceRowHeight(m.sourcePaneWidth())) - m.srcTop = top + 1 - return - } - if len(m.disasmInst) == 0 { - return + m.byteViews.CaptureViewportTop(m.viewContextPtr(), hexraw.Raw) } - w := m.disasmRenderWidth() - visible := m.disasmViewportHeight() - rowHeight := m.disasmRowHeight(w) - next := scrollViewportTop(m.disasmTop, len(m.disasmInst), visible, delta, rowHeight) - if next == m.disasmTop && delta < 0 && m.disasmTop == 0 && m.disasmPosLo > 0 { - if m.loadDisasmWindowAboveForScroll(delta, visible) { - return - } - } - m.disasmTop = next -} - -func (m *Model) loadDisasmWindowAboveForScroll(delta, visible int) bool { - if len(m.disasmInst) == 0 || m.disasmPosLo <= 0 { - return false - } - img := m.file.ExecImage() - oldFirst := m.disasmInst[0].Addr - curAddr := m.disasmInst[m.disasmCur].Addr - if !m.loadDisasmWindow(img.AddrAt(m.disasmPosLo-1), m.disasmMaxBytes-m.disasmOverlapBytes()) { - return false - } - m.disasmCur = m.instIndexAtOrAfterAddr(curAddr) - top := m.disasmCur + delta - if idx, found := m.instIndexForAddr(oldFirst); found { - top = idx + delta - } - w := m.disasmRenderWidth() - rowHeight := func(i int) int { return m.disasmInstVisualHeight(i, w) } - m.disasmTop = viewportTop(top, len(m.disasmInst), visible, rowHeight) - return true } func scrollViewportTop(top, n, visible, delta int, rowHeight func(int) int) int { if n <= 0 { return 0 } - return viewportTop(top+delta, n, visible, rowHeight) -} - -// scrollByteViewportTop scrolls a byte view's top by delta rows, stepping along -// the address-aware row grid (see view_hex.go) and clamping so the last screen -// stays full. delta == 0 just normalizes top to a valid row-start. -func (m *Model) scrollByteViewportTop(md mode, data byteSource, top, visibleRows, delta int, addrAt func(pos int) uint64) int { - n := data.Len() - if n <= 0 { - return 0 - } - top = m.hexRowTop(md, top, addrAt) - for ; delta > 0 && top < n; delta-- { - next := m.hexRowSpan(md, data, top, addrAt).end - if next >= n || next <= top { - break - } - top = next - } - for ; delta < 0 && top > 0; delta++ { - top = m.hexPrevRowTop(md, top, addrAt) - } - if maxTop := m.hexMaxTop(md, data, visibleRows, addrAt); top > maxTop { - top = maxTop - } - return top + return layout.ViewportTop(top+delta, n, visible, rowHeight) } +// handleSearchPopupClick toggles a switch in the search prompt's strip. The +// overlay is re-rendered to recover its centred geometry, then the click's +// content column is mapped through the same Switches() the render used. func (m *Model) handleSearchPopupClick(x, y int) { - modal := m.renderSearchModal() - mw := lipgloss.Width(modal) - mh := lipgloss.Height(modal) - left := (m.width - mw) / 2 - top := (m.height - mh) / 2 - // Translate to content coordinates inside modalStyle's RoundedBorder (1) + - // Padding(1,2): x offset 3, y offset 2. + box := m.search.Render(m.modalContext(), m) + left := (m.width - lipgloss.Width(box)) / 2 + top := (m.height - lipgloss.Height(box)) / 2 + // Translate to content coordinates inside the frame's border (1) + padding + // (1,2): x offset 3, y offset 2. cx := x - (left + 3) cy := y - (top + 2) - if cy != searchSwitchLine || cx < 0 { + if cy != searchmodal.SwitchLine { return } - pos := 0 - sepW := lipgloss.Width(searchSwitchSep) - for _, sw := range m.searchSwitches() { - w := lipgloss.Width(sw.label()) - if cx >= pos && cx < pos+w { - sw.toggle() - return - } - pos += w + sepW - } + m.search.ClickAt(m, cx) } // handleDoubleClick activates the item under a double-click: follow the address @@ -495,7 +437,7 @@ func (m *Model) handleDoubleClick() { switch m.mode { case modeDisasm: m.followCurrentDisasm() - case modeSections, modeSymbols, modeStrings, modeSources, modeLibs: + case modeSections, modeSymbols, modeStrings, modeSources, modeLibs, modeRelocs: m.dispatchViewKey(nil, "enter") } } @@ -503,10 +445,10 @@ func (m *Model) handleDoubleClick() { // followCurrentDisasm follows the first in-file address on the current disasm // line — the mouse equivalent of pressing Enter in the disasm view. func (m *Model) followCurrentDisasm() { - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { return } - inst := m.disasmInst[m.disasmCur] + inst := m.dasm.Inst[m.dasm.Cur] if target, ok := m.followableAddr(inst.Text); ok { m.loadDisasmAt(target) } else { @@ -521,11 +463,10 @@ func (m *Model) handleClick(x, y int) bool { if bodyRow < 0 || y >= m.height-1 { return false } - // The Symbols status line (first body row) carries clickable toggle buttons. - if m.mode == modeSymbols && bodyRow == 0 && !m.symbolsFilter.Focused() { - if m.clickSymbolFacet(x) { - return true - } + // Every table view's status line carries clickable toggle chips; clicking one + // is that chip's key arriving by mouse. + if v := m.current(); bodyRow == v.statusRow() && v.clickStatus(x) { + return true } if m.isTableHeaderRow(bodyRow) { m.handleSortableHeaderClick(x, bodyRow) @@ -535,131 +476,31 @@ func (m *Model) handleClick(x, y int) bool { // same way: find the rendered top, then the item at (bodyRow - headerRows). if g, ok := m.listGeometryFor(); ok { top := m.visualTopForView(*g.cur, *g.top, g.n, g.visible(m.bodyHeight()), g.rowHeight) - if idx, ok := visualItemAtRow(top, g.n, bodyRow-g.headerRows, g.rowHeight); ok { + if idx, ok := layout.VisualItemAtRow(top, g.n, bodyRow-g.headerRows, g.rowHeight); ok { *g.cur = idx // Clicking a tree group toggles it (collapse/expand the lines below). switch { - case m.mode == modeSymbols && idx < len(m.symbolsRows) && m.symbolsRows[idx].node.leaf < 0: - m.toggleSymbolNode() - case m.mode == modeSources && idx < len(m.sourcesRows) && m.sourcesRows[idx].node.leaf < 0: - m.toggleSourceNode() - case m.mode == modeLibs && idx < len(m.libsRows) && m.libsRows[idx].node.leaf < 0: - m.toggleLibNode() + case m.mode == modeSymbols && idx < len(m.symbols.Rows) && m.symbols.Rows[idx].Node.Leaf < 0: + m.symbols.ToggleNode() + case m.mode == modeSources && idx < len(m.sources.Rows) && m.sources.Rows[idx].Node.Leaf < 0: + m.sources.ToggleNode(m.viewContext()) + case m.mode == modeLibs && idx < len(m.libs.Rows) && m.libs.Rows[idx].Node.Leaf < 0: + m.libs.ToggleNode(m.viewContext()) } } return false } switch m.mode { case modeStrings: // compact · flow: map the click to the string under it - if idx, ok := m.flowStringAt(m.renderedStringsTop, bodyRow-1, x); ok { - m.stringsCur = idx + if idx, ok := m.strs.FlowStringAt(m.viewContext(), m.strs.RenderedTop, bodyRow-1, x); ok { + m.strs.Cur = idx } case modeHex: - m.ensureHex() - top := m.hexVisibleTop(modeHex, m.hexCur, m.hexTop, max(1, m.bodyHeight()-1), m.hexImg.AddrAt) - if m.viewportDetached { - top = m.scrollByteViewportTop(modeHex, m.hexImg, m.hexTop, max(1, m.bodyHeight()-1), 0, m.hexImg.AddrAt) - } - m.hexCur = m.clickByte(modeHex, m.hexImg, top, m.hexCur, x, bodyRow, m.hexImg.AddrAt) + m.byteViews.Click(m.viewContextPtr(), hexraw.Hex, x, bodyRow) case modeRaw: - m.ensureRaw() - top := m.hexVisibleTop(modeRaw, m.rawCur, m.rawTop, max(1, m.bodyHeight()-1), identityAddr) - if m.viewportDetached { - top = m.scrollByteViewportTop(modeRaw, rawBytes(m.rawData), m.rawTop, max(1, m.bodyHeight()-1), 0, identityAddr) - } - m.rawCur = m.clickByte(modeRaw, rawBytes(m.rawData), top, m.rawCur, x, bodyRow, identityAddr) + m.byteViews.Click(m.viewContextPtr(), hexraw.Raw, x, bodyRow) case modeDisasm: - if m.sourceFirst && m.srcFile != "" && m.clickInSourcePane(x) { - if ln, ok := m.sourceLineAtBodyRow(bodyRow, m.sourcePaneWidth()); ok { - m.srcCur = ln - m.syncSourceAsm() - } - } else if i, ok := m.instAtBodyRow(bodyRow); ok { - m.disasmCur = i - } + m.dasm.Click(m.viewContextPtr(), m.dasmEnv(), x, bodyRow) } return false } - -func (m *Model) clickInSourcePane(x int) bool { - // In the disasm view's source-first split, the source pane is on the left. - return x < m.width/2 -} - -func (m *Model) sourcePaneWidth() int { - if m.width <= 1 { - return m.width - } - return m.width / 2 -} - -func (m *Model) sourceLineAtBodyRow(bodyRow, paneW int) (int, bool) { - r := bodyRow - 1 // strip source header row - if r < 0 { - return 0, false - } - src := m.file.SourceLines(m.srcFile) - contentH := max(1, m.bodyHeight()-1) - idx, ok := visualItemAtRow(m.sourceTextTop(paneW, contentH), len(src), r, m.sourceRowHeight(paneW)) - return idx + 1, ok -} - -// clickByte maps a click at (x, bodyRow) onto a byte position in a hex dump. -// Body layout: row 0 is the banner, byte rows follow with bytesPerHexRow bytes -// each. The column→byte mapping lives in view_hex.go so it stays in sync with -// the renderer. -func (m *Model) clickByte(md mode, data byteSource, top, cur, x, bodyRow int, addrAt func(pos int) uint64) int { - r := bodyRow - 1 // strip the banner row - if r < 0 { - return cur - } - bpr := m.hexBytesPerRow() - emitted := 0 - prevSec := "" - if top >= bpr { - prevSec = m.hexSectionName(md, top-bpr, addrAt) - } - for rowStart := top; rowStart < data.Len(); { - if sec := m.hexSectionName(md, rowStart, addrAt); sec != "" && sec != prevSec { - if emitted == r { - return cur // clicked a section-separator row - } - emitted++ - prevSec = sec - } else { - prevSec = sec - } - if emitted == r { - span := m.hexRowSpan(md, data, rowStart, addrAt) - slot := hexColumnToByte(m.file.AddrHexWidth(), bpr, x) - if slot < span.lead { - return cur - } - pos := rowStart + slot - span.lead - if pos < rowStart || pos >= span.end { - return cur - } - if pos >= data.Len() { - pos = data.Len() - 1 - } - return pos - } - emitted++ - rowStart = m.hexRowSpan(md, data, rowStart, addrAt).end - } - return cur -} - -// instAtBodyRow maps a click in the disasm scroller to an instruction index. -// It replays renderDisasmScroll's emit order: a symbol-start instruction is -// preceded by a ":" label line, so rows aren't 1:1 with instructions. -func (m *Model) instAtBodyRow(bodyRow int) (int, bool) { - r := bodyRow - 1 // strip the sticky-symbol row - if r < 0 { - return 0, false - } - visible := max(1, m.bodyHeight()-1) - rowHeight := m.disasmRowHeight(m.disasmRenderWidth()) - top := m.visualTopForView(m.disasmCur, m.disasmTop, len(m.disasmInst), visible, rowHeight) - return visualItemAtRow(top, len(m.disasmInst), r, rowHeight) -} diff --git a/internal/ui/mouse_test.go b/internal/ui/mouse_test.go index 31939d8..c00e980 100644 --- a/internal/ui/mouse_test.go +++ b/internal/ui/mouse_test.go @@ -8,6 +8,9 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/views/sections" + "github.com/rabarbra/exex/internal/ui/views/strs" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) func wheelDownModel() *Model { @@ -16,12 +19,10 @@ func wheelDownModel() *Model { file: &binfile.File{}, mode: modeStrings, layoutState: layoutState{width: 80, height: 24}, - stringsState: stringsState{ - stringsList: make([]binfile.StringEntry, 5000), - }, } - m.stringsFilter = newPromptInput("", "/ ") - m.recomputeStrings() // empty filter → all rows visible + m.strs.List = make([]binfile.StringEntry, 5000) + m.strs.Filter = newPromptInput("", "/ ") + m.strs.Recompute(m.viewContext()) // empty filter → all rows visible return m } @@ -39,7 +40,7 @@ func TestWheelCoalescing(t *testing.T) { if !m.wheelTicking { t.Fatal("first wheel event should start the coalescing tick") } - firstTop := m.stringsTop + firstTop := m.strs.Top if firstTop == 0 { t.Fatal("first wheel event should have scrolled") } @@ -51,15 +52,15 @@ func TestWheelCoalescing(t *testing.T) { t.Fatal("a coalesced wheel event must leave the frame clean so View() is skipped") } } - if m.stringsTop != firstTop { - t.Fatalf("burst scrolled mid-flood (top %d → %d); events should only accumulate", firstTop, m.stringsTop) + if m.strs.Top != firstTop { + t.Fatalf("burst scrolled mid-flood (top %d → %d); events should only accumulate", firstTop, m.strs.Top) } if m.pendingWheel == 0 { t.Fatal("burst should have accumulated pending wheel delta") } m.handleWheelTick() // applies the accumulated delta in one shot - if m.stringsTop == firstTop { + if m.strs.Top == firstTop { t.Fatal("tick should have applied the accumulated scroll") } if m.pendingWheel != 0 { @@ -90,24 +91,24 @@ func TestSectionsHeaderClickSorts(t *testing.T) { }} m := newTestModel(t, f) m.setMode(modeSections) - m.recomputeSections() + m.sections.Recompute() clickBodyRow(m, 7, 1) // Name header. - if m.sectionsSort != secSortName { - t.Fatalf("header click sort = %v, want name", m.sectionsSort) + if m.sections.Sort != sections.SortName { + t.Fatalf("header click sort = %v, want name", m.sections.Sort) } - if got := m.sections[m.sectionsFiltered[0]].Name; got != "alpha" { + if got := m.sections.Sections[m.sections.Filtered[0]].Name; got != "alpha" { t.Fatalf("name sort first = %q, want alpha", got) } clickBodyRow(m, 7, 1) - if !m.sectionsSortDesc { + if !m.sections.SortDesc { t.Fatal("second header click did not reverse sections sort") } - if got := m.sections[m.sectionsFiltered[0]].Name; got != "zeta" { + if got := m.sections.Sections[m.sections.Filtered[0]].Name; got != "zeta" { t.Fatalf("name desc first = %q, want zeta", got) } - if header := ansi.Strip(m.renderSections()); !strings.Contains(header, "Name ") || !strings.Contains(header, "▽") || strings.Contains(header, "Name▽") { + if header := ansi.Strip(m.sections.Render(m.viewContext(), m)); !strings.Contains(header, "Name ") || !strings.Contains(header, "▽") || strings.Contains(header, "Name▽") { t.Fatalf("section header missing descending marker: %q", header) } } @@ -120,24 +121,24 @@ func TestSymbolsHeaderClickSorts(t *testing.T) { }} m := newTestModel(t, f) m.setMode(modeSymbols) - m.recomputeSymbols() + m.symbols.Recompute(m.viewContext()) clickBodyRow(m, 2, 1) // Address header. - if m.symbolsSort != sortByAddr { - t.Fatalf("header click sort = %v, want address", m.symbolsSort) + if m.symbols.Sort != symbols.SortAddr { + t.Fatalf("header click sort = %v, want address", m.symbols.Sort) } - if got := m.file.Symbols[m.symbolsRows[0].node.leaf].Addr; got != 0x1000 { + if got := m.file.Symbols[m.symbols.Rows[0].Node.Leaf].Addr; got != 0x1000 { t.Fatalf("address sort first = %#x, want 0x1000", got) } clickBodyRow(m, 2, 1) - if !m.symbolsSortDesc { + if !m.symbols.SortDesc { t.Fatal("second header click did not reverse symbols sort") } - if got := m.file.Symbols[m.symbolsRows[0].node.leaf].Addr; got != 0x3000 { + if got := m.file.Symbols[m.symbols.Rows[0].Node.Leaf].Addr; got != 0x3000 { t.Fatalf("address desc first = %#x, want 0x3000", got) } - if header := ansi.Strip(m.renderSymbols()); !strings.Contains(header, "Address ") || !strings.Contains(header, "▽") || strings.Contains(header, "Address▽") { + if header := ansi.Strip(m.symbols.Render(m.viewContext(), m)); !strings.Contains(header, "Address ") || !strings.Contains(header, "▽") || strings.Contains(header, "Address▽") { t.Fatalf("symbol header missing descending marker: %q", header) } } @@ -148,23 +149,23 @@ func TestStringsHeaderClickSorts(t *testing.T) { file: binfile.NewRawFile([]byte("abc")), mode: modeStrings, layoutState: layoutState{width: 120, height: 30}, - stringsState: stringsState{stringsList: []binfile.StringEntry{ - {Offset: 0, Addr: 0x3000, HasAddr: true, Len: 1}, - {Offset: 1, Addr: 0x1000, HasAddr: true, Len: 1}, - {Offset: 2, Addr: 0x2000, HasAddr: true, Len: 1}, - }}, } - m.stringsFilter = newPromptInput("", "/ ") - m.recomputeStrings() + m.strs.List = []binfile.StringEntry{ + {Offset: 0, Addr: 0x3000, HasAddr: true, Len: 1}, + {Offset: 1, Addr: 0x1000, HasAddr: true, Len: 1}, + {Offset: 2, Addr: 0x2000, HasAddr: true, Len: 1}, + } + m.strs.Filter = newPromptInput("", "/ ") + m.strs.Recompute(m.viewContext()) clickBodyRow(m, 13, 1) // Address header. - if m.stringsSort != strSortAddr { - t.Fatalf("header click sort = %v, want address", m.stringsSort) + if m.strs.Sort != strs.SortAddr { + t.Fatalf("header click sort = %v, want address", m.strs.Sort) } - if got := m.stringsList[m.stringsFiltered[0]].Offset; got != 1 { + if got := m.strs.List[m.strs.Filtered[0]].Offset; got != 1 { t.Fatalf("address sort first offset = %#x, want 1", got) } - if header := ansi.Strip(m.renderStrings()); !strings.Contains(header, "Address ") || !strings.Contains(header, "△") || strings.Contains(header, "Address△") { + if header := ansi.Strip(m.strs.Render(m.viewContext(), m)); !strings.Contains(header, "Address ") || !strings.Contains(header, "△") || strings.Contains(header, "Address△") { t.Fatalf("strings header missing ascending marker: %q", header) } } @@ -173,13 +174,13 @@ func TestLibsHeaderClickSorts(t *testing.T) { f := &binfile.File{Format: binfile.FormatELF, Info: &binfile.Info{DynamicLibs: []string{"z.so", "a.so"}}} m := newTestModel(t, f) m.setMode(modeLibs) - m.libsTree = false - m.buildLibRows() + m.libs.Tree = false + m.libs.BuildRows(m.viewContext()) first := func() string { - for _, row := range m.libsRows { - if row.node.leaf >= 0 { - return m.file.Info.DynamicLibs[row.node.leaf] + for _, row := range m.libs.Rows { + if row.Node.Leaf >= 0 { + return m.file.Info.DynamicLibs[row.Node.Leaf] } } return "" @@ -188,19 +189,19 @@ func TestLibsHeaderClickSorts(t *testing.T) { t.Fatalf("initial first lib = %q, want a.so", got) } - clickBodyRow(m, 2, m.libsTitleRow()+1) - if m.libsSortDesc { + clickBodyRow(m, 2, m.libs.TitleRow(m.viewContext())+1) + if m.libs.SortDesc { t.Fatal("click below libs header sorted instead of selecting a row") } - clickBodyRow(m, 2, m.libsTitleRow()) - if !m.libsSortDesc { + clickBodyRow(m, 2, m.libs.TitleRow(m.viewContext())) + if !m.libs.SortDesc { t.Fatal("libs header click did not reverse sort") } if got := first(); got != "z.so" { t.Fatalf("descending first lib = %q, want z.so", got) } - if header := ansi.Strip(m.renderLibsHeader()); !strings.Contains(header, "Needed libraries ") || !strings.Contains(header, "▽") || strings.Contains(header, "Needed libraries▽") { + if header := ansi.Strip(m.libs.Render(m.viewContext(), m)); !strings.Contains(header, "Needed libraries ") || !strings.Contains(header, "▽") || strings.Contains(header, "Needed libraries▽") { t.Fatalf("libs header missing descending marker: %q", header) } } diff --git a/internal/ui/nav.go b/internal/ui/nav.go index 706653d..5b80b7e 100644 --- a/internal/ui/nav.go +++ b/internal/ui/nav.go @@ -9,37 +9,6 @@ import ( tea "charm.land/bubbletea/v2" ) -// pageStep returns how many list items make up one screen "page": the number of -// items whose stacked rendered heights fill visibleLines, starting at item from -// (the current top of the viewport), clamped to at least 1. rowHeight gives each -// item's line count (1 for single-line rows). Paging by this many items advances -// the view by about one screen instead of overshooting when rows carry chrome -// (header/filter lines reduce visibleLines) or wrap onto multiple lines. -func pageStep(from, n, visibleLines int, rowHeight func(int) int) int { - if visibleLines < 1 { - visibleLines = 1 - } - used, count := 0, 0 - for i := from; i < n; i++ { - h := rowHeight(i) - if h < 1 { - h = 1 - } - if count > 0 && used+h > visibleLines { - break - } - used += h - count++ - if used >= visibleLines { - break - } - } - if count < 1 { - count = 1 - } - return count -} - // listPage is the page size (in items) for the current list view's pgup/pgdown, // as measured at the last render. It advances by one screenful minus one item so // a row of context carries over between pages. Falls back to a sane estimate @@ -54,97 +23,6 @@ func (m *Model) listPage() int { return max(1, m.bodyHeight()-3) } -// navKey applies a standard list-navigation key (up/down/k/j, pgup/pgdown, -// home/end/G) to a cursor over n items, paging by page rows. It returns true -// when it consumed the key (so the caller can stop), and always leaves the -// cursor in [0, n-1] — or 0 for an empty list. `[`/`]` page up/down here too: -// only the list views route through navKey (disasm/hex/source-open handle those -// keys themselves), so paging with them is free in exactly the list views. -func navKey(cur *int, n, page int, key string) bool { - switch key { - case "up", "k": - *cur-- - case "down", "j": - *cur++ - case "pgup", "[": - *cur -= page - case "pgdown", "]": - *cur += page - case "home": - *cur = 0 - case "end", "G": - *cur = n - 1 - default: - return false - } - if *cur >= n { - *cur = n - 1 - } - if *cur < 0 { - *cur = 0 - } - return true -} - -// containsFold reports whether s contains substr, ASCII case-insensitively, -// without the allocation that strings.Contains(strings.ToLower(s), substr) costs -// per call. The list filters run this over every row on every keystroke, so the -// allocation matters on large tables (the Strings/Symbols views). substr is -// expected already-lowercased by the caller; non-ASCII bytes compare exactly, -// which is fine for the identifier/path/section text these filters match. -func containsFold(s, substr string) bool { - if substr == "" { - return true - } - for i := 0; i+len(substr) <= len(s); i++ { - if hasFoldPrefixLower(s[i:], substr) { - return true - } - } - return false -} - -// hasFoldPrefixLower reports whether s starts with prefix, where prefix is -// already lowercased and s is folded to lower as it is compared. -func hasFoldPrefixLower(s, prefix string) bool { - for i := 0; i < len(prefix); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - if c != prefix[i] { - return false - } - } - return true -} - -// containsFoldBytes is containsFold over a byte slice (substr already lowercased), -// so the strings filter can scan zero-copy slices into the file image without -// allocating a string per entry. -func containsFoldBytes(b []byte, substr string) bool { - if substr == "" { - return true - } - for i := 0; i+len(substr) <= len(b); i++ { - match := true - for j := 0; j < len(substr); j++ { - c := b[i+j] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - if c != substr[j] { - match = false - break - } - } - if match { - return true - } - } - return false -} - // toggleWrap flips the global long-line wrap and reports it in the footer. func (m *Model) toggleWrap() { m.wrap = !m.wrap @@ -174,7 +52,10 @@ func filterCapture(in *textinput.Model, key string, msg tea.KeyMsg, recompute fu return nil, false // navigation falls through to the view handler } var cmd tea.Cmd + before := in.Value() *in, cmd = in.Update(msg) - recompute() + if in.Value() != before { + recompute() + } return cmd, true } diff --git a/internal/ui/nav_test.go b/internal/ui/nav_test.go index b7fb364..208aab1 100644 --- a/internal/ui/nav_test.go +++ b/internal/ui/nav_test.go @@ -8,6 +8,10 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/view" + "github.com/rabarbra/exex/internal/ui/views/sources" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) func TestSymbolsTreeGrouping(t *testing.T) { @@ -29,47 +33,46 @@ func TestSymbolsTreeGrouping(t *testing.T) { t.Fatalf("New: %v", err) } m.width, m.height = 120, 30 - m.symbolsTree = true + m.symbols.Tree = true m.setMode(modeSymbols) - m.recomputeSymbols() + m.symbols.Recompute(m.viewContext()) // Expect a compressed "Io.Writer." root whose children are the "Allocating." // group plus two leaves shown by their path from the root: "Discarding.drain" // (a single symbol → not its own group, and crucially NOT nested under // Allocating) and "alignBufferOptions". - roots := buildScopedTree(m.symbolsFiltered, func(i int) string { return m.file.Symbols[i].Display() }) - if len(roots) != 1 || roots[0].label != "Io.Writer." { + roots := layout.BuildScopedTree(m.symbols.Filtered, func(i int) string { return m.file.Symbols[i].Display() }) + if len(roots) != 1 || roots[0].Label != "Io.Writer." { t.Fatalf("root = %v, want single Io.Writer.", labelsOf(roots)) } - if got := labelsOf(roots[0].children); strings.Join(got, "|") != "Allocating.|Discarding.drain|alignBufferOptions" { + if got := labelsOf(roots[0].Children); strings.Join(got, "|") != "Allocating.|Discarding.drain|alignBufferOptions" { t.Fatalf("Io.Writer. children = %v", got) } // "Discarding.drain" must be a leaf, not an internal (collapsible) node. - if roots[0].children[1].leaf < 0 { + if roots[0].Children[1].Leaf < 0 { t.Fatalf("Discarding.drain should be a leaf, not a group") } - // Collapsing the Io.Writer. root should hide everything beneath it. - full := len(m.symbolsRows) - m.symbolsCollapsed = map[string]bool{"Io.Writer.": true} - m.buildSymbolRows() - if len(m.symbolsRows) != 1 { - t.Fatalf("collapsed root: %d visible rows, want 1 (was %d)", len(m.symbolsRows), full) + // Collapsing everything hides all rows beneath the single Io.Writer. root. + full := len(m.symbols.Rows) + m.symbols.SetAllCollapsed(true) + if len(m.symbols.Rows) != 1 { + t.Fatalf("collapsed root: %d visible rows, want 1 (was %d)", len(m.symbols.Rows), full) } // Expand-all clears collapse; collapse-all leaves only the roots/internal heads. - m.symbolsCur = 0 - m.setAllSymbolsCollapsed(false) - if len(m.symbolsRows) != full { - t.Fatalf("expand all: %d rows, want %d", len(m.symbolsRows), full) + m.symbols.Cur = 0 + m.symbols.SetAllCollapsed(false) + if len(m.symbols.Rows) != full { + t.Fatalf("expand all: %d rows, want %d", len(m.symbols.Rows), full) } // t toggles back to the flat table: one row per symbol, full names. - m.updateSymbols("t") - if len(m.symbolsRows) != len(syms) { - t.Fatalf("flat view: %d rows, want %d", len(m.symbolsRows), len(syms)) + m.symbols.Update(m.viewContext(), m, "t") + if len(m.symbols.Rows) != len(syms) { + t.Fatalf("flat view: %d rows, want %d", len(m.symbols.Rows), len(syms)) } - if got := ansi.Strip(strings.TrimSpace(m.symbolRows(3, m.file.AddrHexWidth())[0])); !strings.Contains(got, "Io.Writer.Discarding.drain") { + if got := ansi.Strip(strings.TrimSpace(m.symbols.RowLines(m.viewContext(), 3)[0])); !strings.Contains(got, "Io.Writer.Discarding.drain") { t.Fatalf("flat row should show full name, got %q", got) } } @@ -88,39 +91,39 @@ func TestSymbolsTreeKeys(t *testing.T) { t.Fatalf("New: %v", err) } m.width, m.height = 120, 30 - m.symbolsTree = true + m.symbols.Tree = true m.setMode(modeSymbols) - m.recomputeSymbols() + m.symbols.Recompute(m.viewContext()) rowLabel := func(i int) string { - if i < 0 || i >= len(m.symbolsRows) { + if i < 0 || i >= len(m.symbols.Rows) { return "" } - return m.symbolsRows[i].node.label + return m.symbols.Rows[i].Node.Label } // Tree: ▾ a. → {▾ b. → x,y; c.z}; top. Row 0 is the "a." group. if rowLabel(0) != "a." { t.Fatalf("row 0 = %q, want a.", rowLabel(0)) } - full := len(m.symbolsRows) + full := len(m.symbols.Rows) // Enter on the (expanded) root recursively collapses everything below it. - m.symbolsCur = 0 - m.updateSymbols("enter") - if len(m.symbolsRows) >= full || !m.isSymbolCollapsed("a.") { - t.Fatalf("enter on expanded root did not collapse subtree (%d rows)", len(m.symbolsRows)) + m.symbols.Cur = 0 + m.symbols.Update(m.viewContext(), m, "enter") + if len(m.symbols.Rows) >= full || !m.symbols.IsCollapsed("a.") { + t.Fatalf("enter on expanded root did not collapse subtree (%d rows)", len(m.symbols.Rows)) } // Right expands the root one level. - m.updateSymbols("right") - if m.isSymbolCollapsed("a.") { + m.symbols.Update(m.viewContext(), m, "right") + if m.symbols.IsCollapsed("a.") { t.Fatal("right should expand the a. group") } // Expand everything, then Left on a deep leaf folds its parent branch. - m.setAllSymbolsCollapsed(false) - full2 := len(m.symbolsRows) + m.symbols.SetAllCollapsed(false) + full2 := len(m.symbols.Rows) leaf := -1 - for i, r := range m.symbolsRows { - if r.node.leaf >= 0 && r.depth >= 2 { + for i, r := range m.symbols.Rows { + if r.Node.Leaf >= 0 && r.Depth >= 2 { leaf = i break } @@ -128,10 +131,10 @@ func TestSymbolsTreeKeys(t *testing.T) { if leaf < 0 { t.Fatal("no deep leaf row found") } - m.symbolsCur = leaf - m.updateSymbols("left") - if len(m.symbolsRows) >= full2 { - t.Fatalf("left on a leaf did not fold its parent branch (%d→%d)", full2, len(m.symbolsRows)) + m.symbols.Cur = leaf + m.symbols.Update(m.viewContext(), m, "left") + if len(m.symbols.Rows) >= full2 { + t.Fatalf("left on a leaf did not fold its parent branch (%d→%d)", full2, len(m.symbols.Rows)) } } @@ -146,79 +149,80 @@ func TestSymbolsFacetClick(t *testing.T) { } m.width, m.height = 120, 20 m.setMode(modeSymbols) - m.recomputeSymbols() - _ = m.renderSymbols() // populates m.symbolFacets with x ranges + m.symbols.Recompute(m.viewContext()) + _ = m.symbols.Render(m.viewContext(), m) // populates m.symbols.Chips with x ranges - hit := func(k facetKind) (int, bool) { - for _, fc := range m.symbolFacets { - if fc.kind == k { - return (fc.start + fc.end) / 2, true + // The chip carrying key k, clicked in its middle. + hit := func(key string) (int, bool) { + for _, c := range m.symbols.Chips { + if c.Key == key { + return (c.Start + c.End) / 2, true } } return 0, false } - // Clicking the sort button advances the sort. - x, ok := hit(facetSort) + // Clicking the sort chip advances the sort, exactly as pressing `s` does. + x, ok := hit("s") if !ok { - t.Fatal("no sort facet rendered") + t.Fatal("no sort chip rendered") } - if m.symbolsSort != sortByName { - t.Fatalf("precondition: sort = %v", m.symbolsSort) + if m.symbols.Sort != symbols.SortName { + t.Fatalf("precondition: sort = %v", m.symbols.Sort) } - if !m.clickSymbolFacet(x) { - t.Fatal("click on sort facet missed") + if !m.symbols.ClickStatus(m.viewContext(), m, x) { + t.Fatal("click on the sort chip missed") } - if m.symbolsSort != sortByAddr { - t.Fatalf("sort after click = %v, want address", m.symbolsSort) + if m.symbols.Sort != symbols.SortAddr { + t.Fatalf("sort after click = %v, want address", m.symbols.Sort) } - // Clicking the tree button toggles the tree. - _ = m.renderSymbols() - x, ok = hit(facetTree) + // Clicking the view chip toggles the tree. + _ = m.symbols.Render(m.viewContext(), m) + x, ok = hit("t") if !ok { - t.Fatal("no tree facet rendered") + t.Fatal("no view chip rendered") } - was := m.symbolsTree - if !m.clickSymbolFacet(x) { - t.Fatal("click on tree facet missed") + was := m.symbols.Tree + if !m.symbols.ClickStatus(m.viewContext(), m, x) { + t.Fatal("click on the view chip missed") } - if m.symbolsTree == was { - t.Fatal("tree facet click did not toggle tree mode") + if m.symbols.Tree == was { + t.Fatal("view chip click did not toggle tree mode") } } func TestSourcesTree(t *testing.T) { - m := &Model{} - m.sourcesFiles = []string{ + st := &sources.State{} + st.Files = []string{ "/home/u/proj/src/a.c", "/home/u/proj/src/b.c", "/home/u/proj/main.c", } - m.sourcesFiltered = []int{0, 1, 2} - m.sourcesTree = true - m.buildSourceRows() + st.Filtered = []int{0, 1, 2} + st.Tree = true + st.BuildRows(view.Context{}) - roots := buildTree(m.sourcesFiltered, func(i int) string { return m.sourcesFiles[i] }, segPath) - if len(roots) != 1 || roots[0].label != "/home/u/proj/" { + roots := layout.BuildTree(st.Filtered, func(i int) string { return st.Files[i] }, layout.SegPath) + if len(roots) != 1 || roots[0].Label != "/home/u/proj/" { t.Fatalf("root = %v, want /home/u/proj/", labelsOf(roots)) } - if got := labelsOf(roots[0].children); strings.Join(got, "|") != "src/|main.c" { + if got := labelsOf(roots[0].Children); strings.Join(got, "|") != "src/|main.c" { t.Fatalf("children = %v, want src/, main.c", got) } - if len(m.sourcesRows) != 5 { // root + src/ + a.c + b.c + main.c - t.Fatalf("visible rows = %d, want 5", len(m.sourcesRows)) + if len(st.Rows) != 5 { // root + src/ + a.c + b.c + main.c + t.Fatalf("visible rows = %d, want 5", len(st.Rows)) } - m.setAllSourcesCollapsed(true) - if len(m.sourcesRows) != 1 { - t.Fatalf("collapse-all rows = %d, want 1", len(m.sourcesRows)) + st.SetAllCollapsed(view.Context{}, true) + if len(st.Rows) != 1 { + t.Fatalf("collapse-all rows = %d, want 1", len(st.Rows)) } } -func labelsOf(nodes []*treeNode) []string { +func labelsOf(nodes []*layout.TreeNode) []string { var out []string for _, n := range nodes { - out = append(out, n.label) + out = append(out, n.Label) } return out } @@ -243,8 +247,8 @@ func TestPageStep(t *testing.T) { {"empty list still returns at least one", 5, 5, 10, const1, 1}, } for _, tc := range cases { - if got := pageStep(tc.from, tc.n, tc.visible, tc.rowHeight); got != tc.want { - t.Errorf("%s: pageStep(%d,%d,%d) = %d, want %d", tc.name, tc.from, tc.n, tc.visible, got, tc.want) + if got := layout.PageStep(tc.from, tc.n, tc.visible, tc.rowHeight); got != tc.want { + t.Errorf("%s: layout.PageStep(%d,%d,%d) = %d, want %d", tc.name, tc.from, tc.n, tc.visible, got, tc.want) } } } @@ -307,13 +311,13 @@ func TestPageDownWrapLongLinesSymbols(t *testing.T) { m.width, m.height = 80, 30 m.wrap = true m.setMode(modeSymbols) - m.recomputeSymbols() + m.symbols.Recompute(m.viewContext()) - assertPageWithinViewport(t, m, &m.symbolsCur, - m.symbolRowHeight, - func() { _ = m.renderSymbols() }, - func() { m.updateSymbols("pgdown") }, - func() { m.updateSymbols("pgup") }) + assertPageWithinViewport(t, m, &m.symbols.Cur, + m.symbols.RowHeightFn(m.viewContext()), + func() { _ = m.symbols.Render(m.viewContext(), m) }, + func() { m.symbols.Update(m.viewContext(), m, "pgdown") }, + func() { m.symbols.Update(m.viewContext(), m, "pgup") }) } func TestPageDownWrapLongLinesStrings(t *testing.T) { @@ -337,12 +341,12 @@ func TestPageDownWrapLongLinesStrings(t *testing.T) { m.setMode(modeStrings) // Seed the strings list directly so ensureStrings won't recompute it from the // (empty) synthetic file. - m.stringsList = list - m.recomputeStrings() + m.strs.List = list + m.strs.Recompute(m.viewContext()) - assertPageWithinViewport(t, m, &m.stringsCur, - m.stringRowHeight, - func() { _ = m.renderStrings() }, - func() { m.updateStrings("pgdown") }, - func() { m.updateStrings("pgup") }) + assertPageWithinViewport(t, m, &m.strs.Cur, + m.strs.RowHeightFn(m.viewContext()), + func() { _ = m.strs.Render(m.viewContext(), m) }, + func() { m.strs.Update(m.viewContext(), m, "pgdown") }, + func() { m.strs.Update(m.viewContext(), m, "pgup") }) } diff --git a/internal/ui/new.go b/internal/ui/new.go index 1df80ae..6b36839 100644 --- a/internal/ui/new.go +++ b/internal/ui/new.go @@ -5,13 +5,21 @@ import ( "strings" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/config" "github.com/rabarbra/exex/internal/disasm" "github.com/rabarbra/exex/internal/explorer" "github.com/rabarbra/exex/internal/syntax" + disasmview "github.com/rabarbra/exex/internal/ui/views/disasm" + "github.com/rabarbra/exex/internal/ui/views/hexraw" + infoview "github.com/rabarbra/exex/internal/ui/views/info" + "github.com/rabarbra/exex/internal/ui/views/libs" + "github.com/rabarbra/exex/internal/ui/views/relocs" + "github.com/rabarbra/exex/internal/ui/views/sections" + "github.com/rabarbra/exex/internal/ui/views/sources" + "github.com/rabarbra/exex/internal/ui/views/strs" + "github.com/rabarbra/exex/internal/ui/views/symbols" ) // New constructs a Bubble Tea model for a loaded binary. @@ -33,71 +41,60 @@ func New(f *binfile.File, opts ...Options) (*Model, error) { strFilter := newPromptInput("type to filter…", "/ ") libFilter := newPromptInput("type to filter…", "/ ") relocFilter := newPromptInput("symbol · type · section", "/ ") - gotoInput := newPromptInput("0x401000 or symbol name", "→ ") searchInput := newPromptInput("hex bytes (de ad be ef) or text", "/ ") sysFilter := newPromptInput("name · #num · symbol", "/ ") m := &Model{ - file: f, - dis: d, - cfg: cfg, - theme: NewTheme(cfg), - mode: modeInfo, - layoutState: layoutState{ - headerVP: viewport.New(), + file: f, + dis: d, + cfg: cfg, + theme: NewTheme(cfg), + mode: modeInfo, + layoutState: layoutState{}, + info: infoview.NewState(), + byteViews: hexraw.NewState(), + sections: sections.State{ + Sections: f.Sections, + Segments: f.Segments, + Filter: secFilter, }, - sectionsState: sectionsState{ - sections: f.Sections, - segments: f.Segments, - sectionsFilter: secFilter, - }, - symbolsState: symbolsState{ - symbolsFilter: filter, - symbolsTree: cfg.Behavior.TreeSymbols, - symbolsAbbrev: cfg.Behavior.AbbrevArgs, + symbols: symbols.State{ + Filter: filter, + Tree: cfg.Behavior.TreeSymbols, + Abbrev: cfg.Behavior.AbbrevArgs, }, disasmState: disasmState{ + dasm: disasmview.State{ + SourceState: disasmview.SourceState{ShowSource: true}, + }, disasmMaxBytes: defaultDisasmMaxBytes, disasmSearchWorkers: 0, - showSource: true, - srcVP: viewport.New(), srcHighlighter: syntax.NewHighlighter(sourceSyntaxTheme(cfg)), }, - stringsState: stringsState{ - stringsFilter: strFilter, - }, - sourcesState: sourcesState{ - sourcesFilter: srcFilter, - sourcesTree: cfg.Behavior.TreeSources, - }, - libsState: libsState{ - libsTree: cfg.Behavior.TreeLibs, - libsFilter: libFilter, - }, - relocsState: relocsState{ - relocFilter: relocFilter, + strs: strs.State{ + Filter: strFilter, }, - gotoState: gotoState{ - gotoInput: gotoInput, + sources: sources.State{ + Filter: srcFilter, + Tree: cfg.Behavior.TreeSources, }, - searchState: searchState{ - searchInput: searchInput, - searchForward: true, - searchFromCursor: true, + libs: libs.State{ + Tree: cfg.Behavior.TreeLibs, + Filter: libFilter, }, - syscallState: syscallState{ - syscallFilter: sysFilter, + relocs: relocs.State{ + Filter: relocFilter, }, + gotoState: gotoState{}, interactionState: interactionState{ wrap: cfg.Behavior.DefaultWrap, treeCollapseDefault: cfg.Behavior.TreeCollapsed, - hexInterp: -1, // resolved to the pointer-width hex on first use }, keyState: newKeyState(cfg.Keys), } m.keyLog = os.Getenv("EXEX_KEYLOG") == "1" - m.buildSectionFacets() - m.recomputeSections() + m.sections.BuildFacets() + m.sections.Recompute() // The disassembly is decoded lazily on first open (it can be large); record // where the cursor should land — a guaranteed-executable address chosen by @@ -121,6 +118,14 @@ func New(f *binfile.File, opts ...Options) (*Model, error) { } m.disasmSvc = explorer.NewDisasmService(f, d, m.disasmMaxBytes, m.disasmSearchWorkers) m.disasmInitAddr = explorer.DefaultExecAddr(f, m.disasmTarget) + // The palette overlay owns its prompt but not its styling, so the shell builds + // the widget and hands it over. + m.palette.SetInput(newPromptInput("0x401000 or symbol name", "→ ")) + m.xref.SetInput(newPromptInput("location · text · 0xaddr", "/ ")) + m.findQueryModal.SetInput(newPromptInput("symbol · string · 0xaddr", "search ")) + m.findResults.SetInput(newPromptInput("filter results", "/ ")) + m.search.Init(searchInput) + m.syscalls.SetInput(sysFilter) // Open the configured default view (info when unset). m.switchMode(parseDefaultView(cfg.Behavior.DefaultView)) diff --git a/internal/ui/newviews_test.go b/internal/ui/newviews_test.go index 7742ed2..4bcda2f 100644 --- a/internal/ui/newviews_test.go +++ b/internal/ui/newviews_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/rabarbra/exex/internal/binfile" - "github.com/rabarbra/exex/internal/dump" ) // TestSectionsHeaderMode cycles the Sections view's `t` toggle to the raw-header @@ -15,16 +14,16 @@ func TestSectionsHeaderMode(t *testing.T) { h.goView(modeSections, "2") // The raw header is now a ⇧H overlay (not a Sections sub-mode). h.press("H") - if !h.m().headerActive { + if !h.m().header.Active() { t.Fatalf("H did not open the raw-header overlay") } - out := h.m().renderHeaderModal() + out := h.m().header.Render(h.m().modalContext()) if h.m().file.Format == binfile.FormatELF && !strings.Contains(out, "Machine") { t.Errorf("ELF header overlay missing Machine field:\n%s", out) } // Any non-scroll key closes it. h.press("esc") - if h.m().headerActive { + if h.m().header.Active() { t.Fatal("esc did not close the header overlay") } } @@ -35,7 +34,7 @@ func TestLibsRelocsMode(t *testing.T) { h := newKeyHarness(t, systemBinary(t)) // Relocations are now their own top-level view (key 0). h.goView(modeRelocs, "0") - out := h.m().renderRelocs() + out := h.m().relocs.Render(h.m().viewContext(), h.m()) if len(h.m().file.Relocations()) == 0 { // No decoded relocations (e.g. a Mach-O using dyld chained fixups): the view // shows a clean centred message with no table header, like other empty views. @@ -51,88 +50,66 @@ func TestLibsRelocsMode(t *testing.T) { t.Errorf("relocs view missing header:\n%s", out) } // Filtering by type substring narrows the list. - full := len(h.m().relocFiltered) + full := len(h.m().relocs.Filtered) h.press("/") for _, r := range "JUMP" { h.press(string(r)) } - if h.m().relocFilter.Value() == "" { + if h.m().relocs.Filter.Value() == "" { t.Error("relocs filter did not capture typed text") } - if h.m().libsFilter.Value() != "" { - t.Errorf("relocs filter leaked into libs filter: %q", h.m().libsFilter.Value()) + if h.m().libs.Filter.Value() != "" { + t.Errorf("relocs filter leaked into libs filter: %q", h.m().libs.Filter.Value()) } - if len(h.m().relocFiltered) > full { - t.Errorf("filter grew the list: %d -> %d", full, len(h.m().relocFiltered)) + if len(h.m().relocs.Filtered) > full { + t.Errorf("filter grew the list: %d -> %d", full, len(h.m().relocs.Filtered)) } h.press("esc") // clear filter - if h.m().relocFilter.Value() != "" { - t.Errorf("esc did not clear relocs filter: %q", h.m().relocFilter.Value()) + if h.m().relocs.Filter.Value() != "" { + t.Errorf("esc did not clear relocs filter: %q", h.m().relocs.Filter.Value()) } // s cycles the sort field; r reverses it. - srt0 := h.m().relocSort + srt0 := h.m().relocs.Sort h.press("s") - if h.m().relocSort == srt0 { + if h.m().relocs.Sort == srt0 { t.Error("s did not change the relocation sort field") } - desc0 := h.m().relocSortDesc + desc0 := h.m().relocs.SortDesc h.press("r") - if h.m().relocSortDesc == desc0 { + if h.m().relocs.SortDesc == desc0 { t.Error("r did not reverse the relocation sort") } } -// TestSyscallRowsScopes checks the modal's scope views: function, whole binary -// and unique (with counts). -func TestSyscallRowsScopes(t *testing.T) { - m := &Model{} - m.syscallFnLo, m.syscallFnHi = 0x1000, 0x1100 - m.syscallResults = []dump.SyscallSite{ - {Addr: 0x1010, Num: 1, HasNum: true, Text: "syscall"}, // in func - {Addr: 0x1020, Num: 1, HasNum: true, Text: "syscall"}, // in func, same num - {Addr: 0x2000, Num: 60, HasNum: true, Text: "syscall"}, // outside - } - - m.syscallScope = sysScopeFunc - m.rebuildSyscallRows() - if len(m.syscallShown) != 2 { - t.Errorf("function scope = %d rows, want 2", len(m.syscallShown)) +// TestRelocsViewKeys exercises the relocs view's shared navigation surface: the +// `e` argument-abbreviation toggle (its bind targets are demangled symbol names, +// like Symbols/disasm) and the d/h/m jumps to the patched address. +func TestRelocsViewKeys(t *testing.T) { + h := newKeyHarness(t, systemBinary(t)) + h.goView(modeRelocs, "0") + if len(h.m().file.Relocations()) == 0 { + t.Skip("no decoded relocations to navigate") } - m.syscallScope = sysScopeAll - m.rebuildSyscallRows() - if len(m.syscallShown) != 3 { - t.Errorf("all scope = %d rows, want 3", len(m.syscallShown)) + // `e` flips the global argument-abbreviation state (shared with Symbols/disasm) + // and must not leave the relocs view. + abbrev0 := h.m().symbols.Abbrev + h.press("e") + if h.m().symbols.Abbrev == abbrev0 { + t.Error("e did not toggle argument abbreviation from the relocs view") } - - m.syscallScope = sysScopeUnique - m.rebuildSyscallRows() - if len(m.syscallShown) != 2 { - t.Fatalf("unique scope = %d rows, want 2 (#1, #60)", len(m.syscallShown)) - } - // #1 appears twice; the unique row should carry that count. - for _, r := range m.syscallShown { - if r.site.Num == 1 && r.count != 2 { - t.Errorf("unique count for #1 = %d, want 2", r.count) - } + if h.m().mode != modeRelocs { + t.Errorf("e left the relocs view: mode = %v", h.m().mode) } + h.press("e") // toggle back - // Full scope aggregates the separate full result set (binary + libs), tagging - // origin. - m.syscallFull = []dump.SyscallSite{ - {Num: 1, HasNum: true, Text: "syscall", Origin: "this binary"}, - {Num: 2, HasNum: true, Text: "syscall", Origin: "libc.so.6"}, - {Num: 2, HasNum: true, Text: "syscall", Origin: "libc.so.6"}, - } - m.syscallScope = sysScopeFull - m.rebuildSyscallRows() - if len(m.syscallShown) != 2 { - t.Fatalf("full scope = %d rows, want 2", len(m.syscallShown)) - } - for _, r := range m.syscallShown { - if r.site.Num == 2 && (r.count != 2 || r.site.Origin != "libc.so.6") { - t.Errorf("full #2 = count %d origin %q, want 2 / libc.so.6", r.count, r.site.Origin) + // d/h/m jump to the patched address in the disasm/hex/raw views. + for key, want := range map[string]mode{"h": modeHex, "m": modeRaw} { + h.goView(modeRelocs, "0") + h.press(key) + if h.m().mode != want { + t.Errorf("relocs %q: mode = %v, want %v", key, h.m().mode, want) } } } diff --git a/internal/ui/paste_test.go b/internal/ui/paste_test.go index 3594f31..cdff4d6 100644 --- a/internal/ui/paste_test.go +++ b/internal/ui/paste_test.go @@ -17,16 +17,15 @@ func TestPasteIntoGotoModal(t *testing.T) { layoutState: layoutState{width: 120, height: 30}, file: &binfile.File{Symbols: []binfile.Symbol{{Name: "main", Addr: 0x1000}}}, } - m.gotoInput = newPromptInput("", "→ ") - m.gotoActive = true - m.gotoInput.Focus() + m.palette.SetInput(newPromptInput("", "→ ")) + m.palette.Open(m) upd, _ := m.Update(tea.PasteMsg{Content: "main"}) got := upd.(*Model) - if v := got.gotoInput.Value(); v != "main" { + if v := got.palette.Value(); v != "main" { t.Fatalf("goto value after paste = %q, want %q", v, "main") } - if len(got.gotoResults) == 0 { + if len(got.palette.Results()) == 0 { t.Fatal("paste did not re-run the goto match") } } @@ -37,12 +36,11 @@ func TestPasteIntoSearchModal(t *testing.T) { layoutState: layoutState{width: 120, height: 30}, file: &binfile.File{}, } - m.searchInput = newPromptInput("", "/ ") - m.searchActive = true - m.searchInput.Focus() + m.search.Init(newPromptInput("", "/ ")) + m.search.Open() upd, _ := m.Update(tea.PasteMsg{Content: "de ad be ef"}) - if v := upd.(*Model).searchInput.Value(); v != "de ad be ef" { + if v := upd.(*Model).search.Value(); v != "de ad be ef" { t.Fatalf("search value after paste = %q, want %q", v, "de ad be ef") } } diff --git a/internal/ui/perfstats.go b/internal/ui/perfstats.go index 492a293..26832bb 100644 --- a/internal/ui/perfstats.go +++ b/internal/ui/perfstats.go @@ -57,10 +57,9 @@ func RenderViewStats(f *binfile.File, w, h, runs int) []PerfStat { mm.switchMode(v.mode) // Finish any background decode the disasm view kicked off so the frame is // measured fully rendered, not mid-"decoding…". - for mm.disasmDecoding { - addr := mm.disasmPendingAddr - win, insts := mm.decodeDisasmAt(addr, mm.disasmLeadBytes()) - m, _ = mm.Update(disasmReadyMsg{addr: addr, posLo: win.Start, posHi: win.End, insts: insts}) + for mm.dasm.Decoding { + addr := mm.dasm.PendingAddr + m, _ = mm.Update(disasmReadyMsg{addr: addr, span: mm.decodeDisasmAt(addr, mm.disasmLeadBytes())}) mm = m.(*Model) } diff --git a/internal/ui/render.go b/internal/ui/render.go index 9e34927..1e44815 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -1,16 +1,17 @@ package ui -// Presentation primitives shared by every view: width-aware padding and -// truncation, line wrapping with hanging indent, the scroll-window math that -// keeps a cursor visible across variable-height rows, hex byte rendering, path -// colouring, and the modal overlay compositor. These are pure string helpers -// with no dependency on Model, kept separate from the model/dispatch in app.go. +// Presentation glue that still needs the Model or Theme: hex byte rendering, +// path colouring, themed background/title styling, and the modal overlay +// compositor. The Model-independent geometry — padding, truncation, line +// wrapping with hanging indent, and the scroll-window math — lives in the +// internal/ui/layout package; this file wires it into the themed UI. import ( "strings" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/ui/layout" ) // bytesHex renders up to maxN bytes as compact, per-byte-coloured hex. @@ -33,69 +34,10 @@ func bytesHex(b []byte, maxN int) string { return sb.String() } -// bytesHexSpaced is bytesHex with a space between bytes ("01 00 00 14"), padded -// to the maxN-byte column width (maxN*3-1). Used when behavior.spaced_disasm_bytes -// is on, matching the `-o disasm` dump. -func bytesHexSpaced(b []byte, maxN int) string { - if len(b) > maxN { - b = b[:maxN] - } - var sb strings.Builder - for i, x := range b { - if i > 0 { - sb.WriteByte(' ') - } - sb.WriteString(byteHex[x]) - } - want := max(0, maxN*3-1) - if visible := max(0, len(b)*3-1); visible < want { - sb.WriteString(strings.Repeat(" ", want-visible)) - } - return sb.String() -} - -// placeCentred renders msg as a dim, centred block within w_view × h, wrapping the -// text to the window width (capped for readability) so it stays inside narrow or -// short terminals instead of overflowing. -func (m *Model) placeCentred(msg string, h int) string { - w := clamp(m.width-4, 1, 60) - styled := m.theme.srcShadowStyle.Width(w).Align(lipgloss.Center).Render(msg) - return lipgloss.Place(m.width, max(1, h), lipgloss.Center, lipgloss.Center, styled) -} - // emptyBody centres a dim message in the whole body area, for a view that has no // entries at all (no filter/header rows to keep). func (m *Model) emptyBody(msg string) string { - return m.placeCentred(msg, m.bodyHeight()) -} - -// emptyList renders a list view's empty state: the leading rows (filter / column -// header) are kept and a dim message is centred in the body below them, so an -// empty (or fully-filtered) table reads clearly instead of as a blank area. -func (m *Model) emptyList(msg string, leading ...string) string { - bodyH := m.bodyHeight() - rows := append(leading, strings.Split(m.placeCentred(msg, bodyH-len(leading)), "\n")...) - return padBodyRows(rows, m.width, bodyH) -} - -// truncateMiddle keeps both ends of a string visible within n columns. -func truncateMiddle(s string, n int) string { - if n <= 0 { - return "" - } - plain := ansi.Strip(s) - if lipgloss.Width(plain) <= n { - return plain - } - if n <= 3 { - return truncateANSI(plain, n) - } - leftW := (n - 1) / 2 - rightW := n - 1 - leftW - totalW := lipgloss.Width(plain) - left := ansi.Truncate(plain, leftW, "") - right := ansi.TruncateLeft(plain, max(0, totalW-rightW), "") - return left + "…" + right + return m.viewContext().EmptyBody(msg) } // wrapStatus returns the footer label for the current wrap setting. @@ -151,434 +93,31 @@ func (t *Theme) pathPrefixStyle(prefix string) lipgloss.Style { return t.pathPalette[h%len(t.pathPalette)] } -// padVisual right-pads s to a minimum display width of w columns (ANSI- and -// width-aware), leaving a string that's already wider untouched. This is the -// cell-accurate equivalent of fmt's "%-*s", which counts runes/bytes rather -// than terminal cells and so misaligns columns containing wide or styled text. -func padVisual(s string, w int) string { - if d := w - lipgloss.Width(s); d > 0 { - return s + strings.Repeat(" ", d) - } - return s -} - -// padRight pads s to exactly w visible columns, truncating when it's longer so -// an over-wide line (e.g. a long demangled symbol) can't wrap and shove the -// layout down behind the status line. -func padRight(s string, w int) string { - pw := lipgloss.Width(s) - switch { - case pw == w: - return s - case pw > w: - // Truncate (width-aware) and pad any remainder — a wide rune straddling - // the boundary can leave the result a cell short. - s = truncateANSI(s, w) - if d := w - lipgloss.Width(s); d > 0 { - s += strings.Repeat(" ", d) - } - return s - default: - return s + strings.Repeat(" ", w-pw) - } -} - -// padBody clamps and pads a rendered body to exactly w by h cells. -func padBody(s string, w, h int) string { - lines := strings.Split(strings.TrimRight(s, "\n"), "\n") - if len(lines) > h { - lines = lines[:h] - } - // Clamp every line to exactly w columns so nothing wraps and shoves the - // layout (and the status line) down. - for i, l := range lines { - if lipgloss.Width(l) != w { - lines[i] = padRight(l, w) - } - } - for len(lines) < h { - lines = append(lines, strings.Repeat(" ", w)) - } - return strings.Join(lines, "\n") -} - -// padBodyRows clamps and pads pre-split rows to exactly w by h cells. -func padBodyRows(lines []string, w, h int) string { - if len(lines) > h { - lines = lines[:h] - } - for i, l := range lines { - lines[i] = padRight(l, w) - } - for len(lines) < h { - lines = append(lines, strings.Repeat(" ", w)) - } - return strings.Join(lines, "\n") -} - func (t Theme) renderViewBackground(s string, w int) string { - return renderBackground(s, w, t.viewStyle) + return t.viewPainter.Fill(s, w) } func renderBackground(s string, w int, st lipgloss.Style) string { - return renderStyle(s, w, st) -} - -func renderStyle(s string, w int, st lipgloss.Style) string { - prefix := stylePrefix(st) - if prefix == "" { - return s - } - lines := strings.Split(s, "\n") - for i, line := range lines { - if w > 0 && lipgloss.Width(line) != w { - line = padRight(line, w) - } - line = strings.ReplaceAll(line, "\x1b[0m", "\x1b[0m"+prefix) - line = strings.ReplaceAll(line, "\x1b[m", "\x1b[m"+prefix) - lines[i] = prefix + line + "\x1b[0m" - } - return strings.Join(lines, "\n") + return layout.RenderStyle(s, w, st) } func (t Theme) viewTitleLine(s string, w int) string { - return renderBackground(padRight(fitANSIWidth(s, w), w), w, t.tableHeaderStyle) + return t.headerPainter.Fill(layout.PadRight(layout.FitANSIWidth(s, w), w), w) } func (t Theme) stickyTitleLine(s string, w int) string { - return renderBackground(padRight(fitANSIWidth(s, w), w), w, t.stickySymStyle) -} - -func stylePrefix(st lipgloss.Style) string { - sample := st.Render("x") - i := strings.IndexByte(sample, 'x') - if i <= 0 { - return "" - } - return sample[:i] + return t.stickyPainter.Fill(layout.PadRight(layout.FitANSIWidth(s, w), w), w) } // tableHeader renders a full-width table header line. func (m *Model) tableHeader(s string) string { - return m.theme.viewTitleLine(truncateMiddle(s, m.width), m.width) -} - -// wrapRows splits s into width-limited rows using ansi.Wrap. -func wrapRows(s string, w int, cutset string) []string { - wrapped := ansi.Wrap(s, w, cutset) - rows := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") - if len(rows) == 0 { - return []string{""} - } - return rows -} - -// hardWrapLongRows splits any row still wider than w columns. -func hardWrapLongRows(rows []string, w int) []string { - out := make([]string, 0, len(rows)) - - for _, row := range rows { - if lipgloss.Width(row) <= w { - out = append(out, row) - continue - } - - out = append(out, wrapRows(row, w, "")...) - } - - return out -} - -// indentContinuationRows applies a hanging indent after the first row. -func indentContinuationRows(rows []string, w int, indent int) []string { - if len(rows) <= 1 { - return rows - } - - prefix := strings.Repeat(" ", indent) - contW := max(1, w-indent) - - out := make([]string, 0, len(rows)) - out = append(out, rows[0]) - - for _, row := range rows[1:] { - cont := strings.TrimLeft(row, " ") - - for _, part := range wrapRows(cont, contW, " \t/.-_:") { - out = append(out, prefix+part) - } - } - - return out -} - -// clamp constrains v to the inclusive range [lo, hi]. -func clamp(v, lo, hi int) int { - return min(max(v, lo), hi) -} - -// renderLineRowsIndented renders a logical line with optional hanging indent. -func renderLineRowsIndented(line string, w int, wrap bool, indent int) []string { - if !wrap { - return []string{padRight(line, w)} - } - - indent = clamp(indent, 0, max(0, w-1)) - - rows := wrapRows(line, w, " \t/.-_:") - rows = hardWrapLongRows(rows, w) - - if indent > 0 { - rows = indentContinuationRows(rows, w, indent) - } - - carryWrapStyle(rows) - - for i := range rows { - rows[i] = padRight(rows[i], w) - } - return rows -} - -// carryWrapStyle makes each wrapped row self-contained. A styled span (e.g. a -// coloured symbol or pointer annotation) split across a line break otherwise -// loses its colour: the cell renderer resets the pen at every line, so a -// continuation row that begins mid-span renders with the default colour. This -// re-emits the SGR active at each break — after any leading indent, so the -// hanging indent stays unstyled — and closes every row that ends mid-span. -func carryWrapStyle(rows []string) { - open := "" - for i, row := range rows { - if open != "" { - j := 0 - for j < len(row) && row[j] == ' ' { - j++ - } - row = row[:j] + open + row[j:] - } - open = lastOpenSGR(open, row) - if open != "" { - row += "\x1b[0m" - } - rows[i] = row - } -} - -// lastOpenSGR returns the SGR sequence still in effect at the end of row, given -// the sequence already open when the row began. A reset ("\x1b[0m" / "\x1b[m") -// clears it; any other SGR replaces it. Styles in this UI are emitted as one -// complete SGR per span (lipgloss does this), so tracking the last sequence is -// sufficient. -func lastOpenSGR(open, row string) string { - for i := 0; i+1 < len(row); i++ { - if row[i] != 0x1b || row[i+1] != '[' { - continue - } - j := i + 2 - for j < len(row) && (row[j] < 0x40 || row[j] > 0x7e) { - j++ - } - if j >= len(row) { - break - } - if row[j] == 'm' { - if seq := row[i : j+1]; seq == "\x1b[0m" || seq == "\x1b[m" { - open = "" - } else { - open = seq - } - } - i = j - } - return open -} - -// appendRenderedRows appends rendered rows until limit is reached. -func appendRenderedRows(lines *[]string, line string, w int, wrap bool, limit int) bool { - return appendRenderedRowsIndented(lines, line, w, wrap, 0, limit) -} - -// appendRenderedRowsIndented appends indented rendered rows until limit is reached. -func appendRenderedRowsIndented(lines *[]string, line string, w int, wrap bool, indent int, limit int) bool { - for _, row := range renderLineRowsIndented(line, w, wrap, indent) { - if len(*lines) >= limit { - return false - } - *lines = append(*lines, row) - } - return len(*lines) < limit + return m.viewContext().TableHeader(s) } // visualTopForView respects detached viewport state when computing list top. func (m *Model) visualTopForView(cur, top, n, visible int, rowHeight func(int) int) int { - if m.viewportDetached { - return viewportTop(top, n, visible, rowHeight) - } - return visualTop(cur, top, n, visible, rowHeight) -} - -// viewportTop clamps a detached viewport top for variable-height rows. -func viewportTop(top, n, visible int, rowHeight func(int) int) int { - if n <= 0 { - return 0 - } - if visible < 1 { - visible = 1 - } - if top < 0 { - top = 0 - } - if top >= n { - top = n - 1 - } - maxTop := maxViewportTop(n, visible, rowHeight) - if top > maxTop { - return maxTop - } - return top -} - -// maxViewportTop returns the latest top row that can fill the viewport. -func maxViewportTop(n, visible int, rowHeight func(int) int) int { - if n <= 0 { - return 0 - } - if visible < 1 { - visible = 1 - } - rows := 0 - top := n - for top > 0 { - h := max(1, rowHeight(top-1)) - if rows+h > visible { - break - } - rows += h - top-- - } - if top == n { - return n - 1 - } - return top -} - -// visualTop returns the nearest top that keeps cur visible. -func visualTop(cur, top, n, visible int, rowHeight func(int) int) int { - if n <= 0 { - return 0 - } - if visible < 1 { - visible = 1 - } - if cur < 0 { - cur = 0 - } - if cur >= n { - cur = n - 1 - } - if top < 0 || cur < top { - top = cur - } - if top >= n { - top = n - 1 - } - if maxTop := maxViewportTop(n, visible, rowHeight); top > maxTop { - top = maxTop - } - if top > cur { - top = cur - } - - // Find the earliest row that can still keep cur visible by walking backward - // only as far as the viewport can fit. This preserves the old top while it's - // valid, but avoids the O(n²) forward scan when the cursor jumps far away - // (End / Ctrl+E on huge symbol or string tables). - minTop := cur - rows := max(1, rowHeight(cur)) - for minTop > 0 { - h := max(1, rowHeight(minTop-1)) - if rows+h > visible { - break - } - rows += h - minTop-- - } - if top < minTop { - top = minTop - } - return top -} - -// visualItemAtRow maps a visual row offset to a logical item index. -func visualItemAtRow(top, n, row int, rowHeight func(int) int) (int, bool) { - if row < 0 { - return 0, false - } - pos := 0 - for i := top; i < n; i++ { - h := max(1, rowHeight(i)) - if row >= pos && row < pos+h { - return i, true - } - pos += h - } - return 0, false -} - -// overlay places fg over bg at column x, row y. Both are pre-rendered strings. -// It is ANSI- and width-aware: the background to the left and right of the -// modal keeps its colours and lines up correctly even when those lines contain -// styled or multi-byte content (e.g. the disasm source-pane border). -func overlay(bg, fg string, x, y int) string { - if x < 0 { - x = 0 - } - if y < 0 { - y = 0 - } - bgLines := strings.Split(bg, "\n") - fgLines := strings.Split(fg, "\n") - for i, fl := range fgLines { - row := y + i - if row >= len(bgLines) { - break - } - bgLine := bgLines[row] - fw := ansi.StringWidth(fl) - - // Left slice: the first x cells of the background, padded if short. - left := ansi.Truncate(bgLine, x, "") - if w := ansi.StringWidth(left); w < x { - left += strings.Repeat(" ", x-w) - } - // Right slice: the background beyond the modal, with its style preserved. - right := ansi.TruncateLeft(bgLine, x+fw, "") - - bgLines[row] = left + "\x1b[0m" + fl + "\x1b[0m" + right - } - return strings.Join(bgLines, "\n") -} - -// fitANSIWidth keeps a styled string intact when it fits within w visible -// columns, and falls back to a plain truncation when it doesn't — so a single -// over-long source line can't break the side-by-side layout while normal-width -// lines retain their syntax colours. -func fitANSIWidth(s string, w int) string { - if w <= 0 { - return "" - } - if lipgloss.Width(s) <= w { - return s - } - return truncateANSI(s, w) + return m.viewContext().VisualTop(cur, top, n, visible, rowHeight) } -// truncateANSI naively truncates while keeping the trailing SGR reset. -func truncateANSI(s string, w int) string { - if w <= 0 { - return "" - } - // ansi.Truncate is width- and escape-aware (and never splits a multi-byte - // rune, unlike a naive byte slice), so it's safe for styled / Unicode text. - return ansi.Truncate(s, w, "") -} +// The scroll/viewport geometry (VisualTop, …) and the modal Overlay compositor +// live in the dependency-free internal/ui/layout package. diff --git a/internal/ui/render_bench_test.go b/internal/ui/render_bench_test.go index 3f36377..20e49e9 100644 --- a/internal/ui/render_bench_test.go +++ b/internal/ui/render_bench_test.go @@ -29,12 +29,8 @@ func benchView(b *testing.B, md mode) { m, _ = m.Update(tea.WindowSizeMsg{Width: 160, Height: 48}) mm := m.(*Model) mm.switchMode(md) - for mm.disasmDecoding { - addr := mm.disasmPendingAddr - win, insts := mm.decodeDisasmAt(addr, mm.disasmLeadBytes()) - m, _ = mm.Update(disasmReadyMsg{addr: addr, posLo: win.Start, posHi: win.End, insts: insts}) - mm = m.(*Model) - } + m = settleDisasmDecode(m) + mm = m.(*Model) b.ReportAllocs() b.ResetTimer() for range b.N { @@ -43,7 +39,12 @@ func benchView(b *testing.B, md mode) { } } -func BenchmarkViewDisasm(b *testing.B) { benchView(b, modeDisasm) } -func BenchmarkViewHex(b *testing.B) { benchView(b, modeHex) } -func BenchmarkViewSymbols(b *testing.B) { benchView(b, modeSymbols) } -func BenchmarkViewRelocs(b *testing.B) { benchView(b, modeRelocs) } +func BenchmarkViewDisasm(b *testing.B) { benchView(b, modeDisasm) } +func BenchmarkViewHex(b *testing.B) { benchView(b, modeHex) } +func BenchmarkViewSymbols(b *testing.B) { benchView(b, modeSymbols) } +func BenchmarkViewRelocs(b *testing.B) { benchView(b, modeRelocs) } +func BenchmarkViewRaw(b *testing.B) { benchView(b, modeRaw) } +func BenchmarkViewStrings(b *testing.B) { benchView(b, modeStrings) } +func BenchmarkViewSections(b *testing.B) { benchView(b, modeSections) } +func BenchmarkViewLibs(b *testing.B) { benchView(b, modeLibs) } +func BenchmarkViewInfo(b *testing.B) { benchView(b, modeInfo) } diff --git a/internal/ui/roadmap_test.go b/internal/ui/roadmap_test.go index ea2a55d..7631f26 100644 --- a/internal/ui/roadmap_test.go +++ b/internal/ui/roadmap_test.go @@ -1,6 +1,8 @@ package ui import ( + searchutil "github.com/rabarbra/exex/internal/bytesearch" + searchmodal "github.com/rabarbra/exex/internal/ui/modals/search" "os" "strings" "testing" @@ -13,8 +15,17 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/ui/layout" + "github.com/rabarbra/exex/internal/ui/views/hexraw" ) +const testBytesPerHexRow = 16 + +func testHexBodyStart(addrW int) int { return 1 + 2 + addrW + 2 } + +func renderHexTest(m *Model) string { return m.byteViews.Render(m.viewContextPtr(), hexraw.Hex) } +func renderRawTest(m *Model) string { return m.byteViews.Render(m.viewContextPtr(), hexraw.Raw) } + func TestSymbolTypeFilterCyclesAndFilters(t *testing.T) { m := &Model{ theme: DefaultTheme(), @@ -24,27 +35,28 @@ func TestSymbolTypeFilterCyclesAndFilters(t *testing.T) { {Name: "sec", Kind: binfile.SymSection}, }}, } - m.symbolsFilter = textinput.New() - m.recomputeSymbols() - if got := len(m.symbolsFiltered); got != 3 { + m.symbols.Filter = textinput.New() + m.symbols.Recompute(m.viewContext()) + if got := len(m.symbols.Filtered); got != 3 { t.Fatalf("initial filtered count = %d, want 3", got) } - m.cycleSymbolKindFilter() - m.recomputeSymbols() - if got := len(m.symbolsFiltered); got != 1 { + m.symbols.Update(m.viewContext(), m, "ctrl+t") + m.symbols.Recompute(m.viewContext()) + if got := len(m.symbols.Filtered); got != 1 { t.Fatalf("func filtered count = %d, want 1", got) } - if sym := m.file.Symbols[m.symbolsFiltered[0]]; sym.Kind != binfile.SymFunc { + if sym := m.file.Symbols[m.symbols.Filtered[0]]; sym.Kind != binfile.SymFunc { t.Fatalf("filtered symbol kind = %v, want func", sym.Kind) } } func TestOpenSearchClearsInputButKeepsRepeatQuery(t *testing.T) { m := &Model{searchState: searchState{searchQuery: "old"}} - m.searchInput = textinput.New() - m.searchInput.SetValue("stale") - m.openSearch() - if got := m.searchInput.Value(); got != "" { + m.search.Init(textinput.New()) + m.search.Open() + m.search.HandleInput(tea.PasteMsg{Content: "stale"}) + m.search.Open() + if got := m.search.Value(); got != "" { t.Fatalf("search input = %q, want empty", got) } if got := m.searchQuery; got != "old" { @@ -73,8 +85,8 @@ func TestGotoUnmappedAddressOpensRaw(t *testing.T) { if m.mode != modeRaw { t.Fatalf("mode = %v, want raw", m.mode) } - if m.rawCur != 0 { - t.Fatalf("raw cursor = %d, want clamped 0", m.rawCur) + if m.byteViews.RawCur != 0 { + t.Fatalf("raw cursor = %d, want clamped 0", m.byteViews.RawCur) } } @@ -121,18 +133,18 @@ func TestDefaultViewAndNavHelpers(t *testing.T) { func TestHexColumnToByteBounds(t *testing.T) { addrW := 8 - bpr := bytesPerHexRow - start := hexBodyStart(addrW) - if got := hexColumnToByte(addrW, bpr, start-10); got != 0 { + bpr := testBytesPerHexRow + start := testHexBodyStart(addrW) + if got := hexraw.ColumnToByte(addrW, bpr, start-10); got != 0 { t.Fatalf("column before hex body = %d, want 0", got) } - if got := hexColumnToByte(addrW, bpr, start); got != 0 { + if got := hexraw.ColumnToByte(addrW, bpr, start); got != 0 { t.Fatalf("first byte column = %d, want 0", got) } - if got := hexColumnToByte(addrW, bpr, start+3*8+1); got != 8 { + if got := hexraw.ColumnToByte(addrW, bpr, start+3*8+1); got != 8 { t.Fatalf("column after midpoint gap = %d, want 8", got) } - if got := hexColumnToByte(addrW, bpr, start+1000); got != bytesPerHexRow-1 { + if got := hexraw.ColumnToByte(addrW, bpr, start+1000); got != testBytesPerHexRow-1 { t.Fatalf("column after row = %d, want last byte", got) } } @@ -142,27 +154,37 @@ func TestHexVisibleTopPreservesUnalignedSectionStart(t *testing.T) { // starts at data position 0x65 / address 0x2003 (lead 3). const sectionStart = 0x65 m := &Model{ - theme: DefaultTheme(), - file: &binfile.File{}, - hexState: hexState{hexImg: binfile.NewImage(make([]byte, 0x200), []binfile.Region{ + theme: DefaultTheme(), + file: &binfile.File{}, + layoutState: layoutState{width: 120, height: 13}, + byteViews: hexraw.State{HexImg: binfile.NewImage(make([]byte, 0x200), []binfile.Region{ {Addr: 0x1000, Size: sectionStart, Off: 0, Name: "A"}, {Addr: 0x2003, Size: 0x200 - sectionStart, Off: sectionStart, Name: "B"}, })}, } - addrAt := m.hexImg.AddrAt - if got := m.hexVisibleTop(modeHex, sectionStart, sectionStart, 10, addrAt); got != sectionStart { + m.byteViews.HexCur = sectionStart + m.byteViews.HexTop = sectionStart + renderHexTest(m) + if got := m.byteViews.HexTop; got != sectionStart { t.Fatalf("hexVisibleTop at section start = 0x%x, want 0x%x", got, sectionStart) } // The row ending the previous section is address-aligned at 0x60. - if got := m.hexVisibleTop(modeHex, sectionStart-1, sectionStart, 10, addrAt); got != 0x60 { + m.byteViews.HexCur = sectionStart - 1 + m.byteViews.HexTop = sectionStart + renderHexTest(m) + if got := m.byteViews.HexTop; got != 0x60 { t.Fatalf("hexVisibleTop before section start = 0x%x, want 0x60", got) } - if got := m.scrollByteViewportTop(modeHex, m.hexImg, sectionStart, 10, -1, addrAt); got != 0x60 { + m.byteViews.HexTop = sectionStart + m.byteViews.Scroll(m.viewContextPtr(), hexraw.Hex, -1) + if got := m.byteViews.HexTop; got != 0x60 { t.Fatalf("scrollByteViewportTop up = 0x%x, want 0x60", got) } // Section B's first row spans the 13 bytes up to the next aligned address. - if got := m.scrollByteViewportTop(modeHex, m.hexImg, sectionStart, 10, 1, addrAt); got != sectionStart+13 { + m.byteViews.HexTop = sectionStart + m.byteViews.Scroll(m.viewContextPtr(), hexraw.Hex, 1) + if got := m.byteViews.HexTop; got != sectionStart+13 { t.Fatalf("scrollByteViewportTop down = 0x%x, want 0x%x", got, sectionStart+13) } } @@ -180,12 +202,12 @@ func TestHexMiddleRowsNeverGap(t *testing.T) { theme: DefaultTheme(), file: &binfile.File{}, layoutState: layoutState{width: 120, height: 12}, - hexState: hexState{hexImg: binfile.NewImage(data, []binfile.Region{{Addr: 0x1029052b8, Size: uint64(len(data)), Off: 0, Name: "__objc_data"}})}, + byteViews: hexraw.State{HexImg: binfile.NewImage(data, []binfile.Region{{Addr: 0x1029052b8, Size: uint64(len(data)), Off: 0, Name: "__objc_data"}})}, } for range 40 { // scroll well past the section start - m.updateHex("down") + m.byteViews.Update(m.viewContextPtr(), m, hexraw.Hex, "down") } - lines := strings.Split(ansi.Strip(m.renderHex()), "\n") + lines := strings.Split(ansi.Strip(renderHexTest(m)), "\n") var body []string for _, ln := range lines { if strings.Contains(ln, "0x0000000") { @@ -198,7 +220,7 @@ func TestHexMiddleRowsNeverGap(t *testing.T) { for i, ln := range body { // Hex column begins after " 0x"+addr digits+" "; check its first slot. addrW := m.file.AddrHexWidth() - first := ln[hexBodyStart(addrW) : hexBodyStart(addrW)+2] + first := ln[testHexBodyStart(addrW) : testHexBodyStart(addrW)+2] if first == " " { t.Fatalf("row %d has a leading gap (mid-section row must be full):\n%s", i, ln) } @@ -216,9 +238,9 @@ func TestHexAndRawRowsSplitAtUnalignedSectionStart(t *testing.T) { theme: DefaultTheme(), file: &binfile.File{Sections: sections}, layoutState: layoutState{width: 120, height: 10}, - rawState: rawState{rawData: raw, rawCur: 0x17, rawTop: 0x14}, + byteViews: hexraw.State{RawData: raw, RawCur: 0x17, RawTop: 0x14}, } - assertSectionBytesBelowSeparator(t, ansi.Strip(rawModel.renderRaw()), "__objc_methname", "43 47 43", "0x0000000000000010") + assertSectionBytesBelowSeparator(t, ansi.Strip(renderRawTest(rawModel)), "__objc_methname", "43 47 43", "0x0000000000000010") hexData := make([]byte, 0x30) copy(hexData[0x10:], []byte{'e', 'd', 0, 'C', 'G', 'C', 'o', 'l', 'o', 'r'}) @@ -227,53 +249,47 @@ func TestHexAndRawRowsSplitAtUnalignedSectionStart(t *testing.T) { file: &binfile.File{Sections: sections}, layoutState: layoutState{width: 120, height: 10}, interactionState: interactionState{viewportDetached: true}, - hexState: hexState{hexImg: binfile.NewImage(hexData, []binfile.Region{ + byteViews: hexraw.State{HexImg: binfile.NewImage(hexData, []binfile.Region{ {Addr: 0x10203cb04, Size: 0x13, Off: 0, Name: "prev"}, {Addr: 0x10203cb17, Size: 0x20, Off: 0x13, Name: "__objc_methname"}, - }), hexCur: 0x13, hexTop: 0x10}, + }), HexCur: 0x13, HexTop: 0x10}, } - assertSectionBytesBelowSeparator(t, ansi.Strip(hexModel.renderHex()), "__objc_methname", "43 47 43", "0x000000010203cb10") + assertSectionBytesBelowSeparator(t, ansi.Strip(renderHexTest(hexModel)), "__objc_methname", "43 47 43", "0x000000010203cb10") } func TestOpeningUnalignedSectionPinsSeparatorAtTop(t *testing.T) { section := binfile.Section{Name: "__objc_methname", Addr: 0x10203cb17, Size: 0x20, Offset: 0x17, FileSize: 0x20, Alloc: true} hexModel := &Model{ - theme: DefaultTheme(), - file: &binfile.File{Sections: []binfile.Section{section}}, - layoutState: layoutState{width: 120, height: 8}, - interactionState: interactionState{ - viewportDetached: true, - renderedHexTop: 99, - }, - hexState: hexState{hexImg: binfile.NewImage([]byte("CGColor.CGContext._device"), []binfile.Region{{Addr: section.Addr, Size: section.FileSize, Off: 0, Name: section.Name}})}, + theme: DefaultTheme(), + file: &binfile.File{Sections: []binfile.Section{section}}, + layoutState: layoutState{width: 120, height: 8}, + interactionState: interactionState{viewportDetached: true}, + byteViews: hexraw.State{HexRenderedTop: 99, HexImg: binfile.NewImage([]byte("CGColor.CGContext._device"), []binfile.Region{{Addr: section.Addr, Size: section.FileSize, Off: 0, Name: section.Name}})}, } hexModel.openHexAt(section.Addr) if hexModel.viewportDetached { t.Fatal("openHexAt did not reattach viewport") } - if hexModel.hexTop != hexModel.hexCur { - t.Fatalf("hexTop = %d, want section cursor %d", hexModel.hexTop, hexModel.hexCur) + if hexModel.byteViews.HexTop != hexModel.byteViews.HexCur { + t.Fatalf("hexTop = %d, want section cursor %d", hexModel.byteViews.HexTop, hexModel.byteViews.HexCur) } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(hexModel.renderHex()), section.Name) + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(hexModel)), section.Name) rawModel := &Model{ - theme: DefaultTheme(), - file: &binfile.File{Sections: []binfile.Section{section}}, - layoutState: layoutState{width: 120, height: 8}, - interactionState: interactionState{ - viewportDetached: true, - renderedRawTop: 99, - }, - rawState: rawState{rawData: []byte("abcdefghijklmnopqrstuvwxyzzzzzzzzzzzzzzzzzzzzzz")}, + theme: DefaultTheme(), + file: &binfile.File{Sections: []binfile.Section{section}}, + layoutState: layoutState{width: 120, height: 8}, + interactionState: interactionState{viewportDetached: true}, + byteViews: hexraw.State{RawRenderedTop: 99, RawData: []byte("abcdefghijklmnopqrstuvwxyzzzzzzzzzzzzzzzzzzzzzz")}, } rawModel.openRawAt(section.Offset) if rawModel.viewportDetached { t.Fatal("openRawAt did not reattach viewport") } - if rawModel.rawTop != rawModel.rawCur { - t.Fatalf("rawTop = %d, want section cursor %d", rawModel.rawTop, rawModel.rawCur) + if rawModel.byteViews.RawTop != rawModel.byteViews.RawCur { + t.Fatalf("rawTop = %d, want section cursor %d", rawModel.byteViews.RawTop, rawModel.byteViews.RawCur) } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(rawModel.renderRaw()), section.Name) + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderRawTest(rawModel)), section.Name) } func TestCurrentUnalignedSectionSnapsPastPreviousSectionGap(t *testing.T) { @@ -288,13 +304,13 @@ func TestCurrentUnalignedSectionSnapsPastPreviousSectionGap(t *testing.T) { theme: DefaultTheme(), file: &binfile.File{Sections: sections}, layoutState: layoutState{width: 120, height: 10}, - hexState: hexState{hexImg: binfile.NewImage(hexData, []binfile.Region{ + byteViews: hexraw.State{HexImg: binfile.NewImage(hexData, []binfile.Region{ {Addr: sections[0].Addr, Size: sections[0].FileSize, Off: 0, Name: sections[0].Name}, {Addr: sections[1].Addr, Size: sections[1].FileSize, Off: 0x4e, Name: sections[1].Name}, - }), hexCur: 0x7f, hexTop: 0}, + }), HexCur: 0x7f, HexTop: 0}, } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(hexModel.renderHex()), sections[1].Name) - if got, want := hexModel.hexTop, 0x4e; got != want { + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(hexModel)), sections[1].Name) + if got, want := hexModel.byteViews.HexTop, 0x4e; got != want { t.Fatalf("hexTop = 0x%x, want current section start 0x%x", got, want) } model, _ := hexModel.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp, X: 10, Y: 4})) @@ -302,7 +318,7 @@ func TestCurrentUnalignedSectionSnapsPastPreviousSectionGap(t *testing.T) { if !hexModel.viewportDetached { t.Fatal("hex wheel up did not detach viewport") } - if got, limit := hexModel.hexTop, 0x4e; got >= limit { + if got, limit := hexModel.byteViews.HexTop, 0x4e; got >= limit { t.Fatalf("hex wheel up top = 0x%x, want before section start 0x%x", got, limit) } @@ -313,10 +329,10 @@ func TestCurrentUnalignedSectionSnapsPastPreviousSectionGap(t *testing.T) { theme: DefaultTheme(), file: &binfile.File{Sections: sections}, layoutState: layoutState{width: 120, height: 10}, - rawState: rawState{rawData: rawData, rawCur: 0x7f, rawTop: 0x10}, + byteViews: hexraw.State{RawData: rawData, RawCur: 0x7f, RawTop: 0x10}, } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(rawModel.renderRaw()), sections[1].Name) - if got, want := rawModel.rawTop, 0x4e; got != want { + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderRawTest(rawModel)), sections[1].Name) + if got, want := rawModel.byteViews.RawTop, 0x4e; got != want { t.Fatalf("rawTop = 0x%x, want current section start 0x%x", got, want) } model, _ = rawModel.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp, X: 10, Y: 4})) @@ -324,7 +340,7 @@ func TestCurrentUnalignedSectionSnapsPastPreviousSectionGap(t *testing.T) { if !rawModel.viewportDetached { t.Fatal("raw wheel up did not detach viewport") } - if got, limit := rawModel.rawTop, 0x4e; got >= limit { + if got, limit := rawModel.byteViews.RawTop, 0x4e; got >= limit { t.Fatalf("raw wheel up top = 0x%x, want before section start 0x%x", got, limit) } } @@ -337,30 +353,27 @@ func TestPinnedUnalignedSectionOverridesStaleDetachedTop(t *testing.T) { hexData := make([]byte, 0x100) copy(hexData[0x67:], []byte("CGColor.CGContext._device")) m := &Model{ - mode: modeHex, - theme: DefaultTheme(), - file: &binfile.File{Sections: sections}, - layoutState: layoutState{width: 120, height: 10}, - interactionState: interactionState{ - viewportDetached: true, - renderedHexTop: 0, - }, - hexState: hexState{hexImg: binfile.NewImage(hexData, []binfile.Region{ + mode: modeHex, + theme: DefaultTheme(), + file: &binfile.File{Sections: sections}, + layoutState: layoutState{width: 120, height: 10}, + interactionState: interactionState{viewportDetached: true}, + byteViews: hexraw.State{HexRenderedTop: 0, HexImg: binfile.NewImage(hexData, []binfile.Region{ {Addr: sections[0].Addr, Size: sections[0].FileSize, Off: 0, Name: sections[0].Name}, {Addr: sections[1].Addr, Size: sections[1].FileSize, Off: 0x67, Name: sections[1].Name}, - }), hexCur: 0x67, hexTop: 0, hexPinnedTop: 0x67, hexPinned: true}, + }), HexCur: 0x67, HexTop: 0, HexPinnedTop: 0x67, HexPinned: true}, } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(m.renderHex()), sections[1].Name) - if got, want := m.hexTop, 0x67; got != want { + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(m)), sections[1].Name) + if got, want := m.byteViews.HexTop, 0x67; got != want { t.Fatalf("hexTop = 0x%x, want pinned section start 0x%x", got, want) } m.wheelSuppressUntil = time.Time{} model, _ := m.handleMouse(tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp, X: 10, Y: 4})) m = model.(*Model) - if m.hexPinned { + if m.byteViews.HexPinned { t.Fatal("wheel up did not clear pinned section") } - if got, limit := m.hexTop, 0x67; got >= limit { + if got, limit := m.byteViews.HexTop, 0x67; got >= limit { t.Fatalf("wheel up top = 0x%x, want before pinned section 0x%x", got, limit) } } @@ -378,23 +391,24 @@ func TestHexSearchReattachesViewportAtUnalignedSectionStart(t *testing.T) { file: &binfile.File{Sections: sections}, layoutState: layoutState{width: 120, height: 10}, interactionState: interactionState{viewportDetached: true}, - searchState: searchState{searchActive: true, searchMode: searchModeText, searchForward: true, searchFromCursor: false}, - hexState: hexState{hexImg: binfile.NewImage(hexData, []binfile.Region{ + byteViews: hexraw.State{HexImg: binfile.NewImage(hexData, []binfile.Region{ {Addr: sections[0].Addr, Size: sections[0].FileSize, Off: 0, Name: sections[0].Name}, {Addr: sections[1].Addr, Size: sections[1].FileSize, Off: 0x4e, Name: sections[1].Name}, - }), hexCur: 0, hexTop: 0}, + }), HexCur: 0, HexTop: 0}, } - m.searchInput = textinput.New() - m.searchInput.SetValue("Vibrant") + m.search.Init(textinput.New()) + m.search.SetOptions(searchmodal.Options{Mode: searchutil.ModeText, Forward: true}) + m.search.Open() + m.search.HandleInput(tea.PasteMsg{Content: "Vibrant"}) model, _ := m.updateSearchInput(keyPress("enter"), "enter") m = model.(*Model) if m.viewportDetached { t.Fatal("search did not reattach viewport") } - if got, want := m.hexCur, 0x4e; got != want { + if got, want := m.byteViews.HexCur, 0x4e; got != want { t.Fatalf("hex search cursor = 0x%x, want 0x%x", got, want) } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(m.renderHex()), sections[1].Name) + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(m)), sections[1].Name) } func TestGhosttyObjcMethnameOpenPinsSeparatorAtTop(t *testing.T) { @@ -412,10 +426,10 @@ func TestGhosttyObjcMethnameOpenPinsSeparatorAtTop(t *testing.T) { } m.width, m.height = 120, 20 m.openHexAt(0x10203cb17) - if m.hexTop != m.hexCur { - t.Fatalf("hexTop = %d addr 0x%x, hexCur = %d addr 0x%x", m.hexTop, m.hexImg.AddrAt(m.hexTop), m.hexCur, m.hexImg.AddrAt(m.hexCur)) + if m.byteViews.HexTop != m.byteViews.HexCur { + t.Fatalf("hexTop = %d addr 0x%x, hexCur = %d addr 0x%x", m.byteViews.HexTop, m.byteViews.HexImg.AddrAt(m.byteViews.HexTop), m.byteViews.HexCur, m.byteViews.HexImg.AddrAt(m.byteViews.HexCur)) } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(m.renderHex()), "__objc_methname") + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(m)), "__objc_methname") } func TestGhosttyObjcMethnameSectionKeyPinsSeparatorAtTop(t *testing.T) { @@ -434,24 +448,22 @@ func TestGhosttyObjcMethnameSectionKeyPinsSeparatorAtTop(t *testing.T) { m.width, m.height = 120, 20 // ] jumps to the next section start and pins the separator at the top. m.openHexAt(0x10203cae0) - model, _ := m.updateHex("]") - m = model.(*Model) - addr := m.hexImg.AddrAt(m.hexCur) + m.byteViews.Update(m.viewContextPtr(), m, hexraw.Hex, "]") + addr := m.byteViews.HexImg.AddrAt(m.byteViews.HexCur) if addr != 0x10203cb17 { t.Fatalf("] landed at 0x%x, want 0x10203cb17", addr) } - if m.hexTop != m.hexCur { - t.Fatalf("hexTop = %d addr 0x%x, hexCur = %d addr 0x%x", m.hexTop, m.hexImg.AddrAt(m.hexTop), m.hexCur, m.hexImg.AddrAt(m.hexCur)) + if m.byteViews.HexTop != m.byteViews.HexCur { + t.Fatalf("hexTop = %d addr 0x%x, hexCur = %d addr 0x%x", m.byteViews.HexTop, m.byteViews.HexImg.AddrAt(m.byteViews.HexTop), m.byteViews.HexCur, m.byteViews.HexImg.AddrAt(m.byteViews.HexCur)) } - assertSeparatorDirectlyUnderBanner(t, ansi.Strip(m.renderHex()), "__objc_methname") + assertSeparatorDirectlyUnderBanner(t, ansi.Strip(renderHexTest(m)), "__objc_methname") // [ jumps back to the previous section, again pinning its separator on top. - model, _ = m.updateHex("[") - m = model.(*Model) - if m.hexTop != m.hexCur { - t.Fatalf("after [ hexTop = %d, hexCur = %d", m.hexTop, m.hexCur) + m.byteViews.Update(m.viewContextPtr(), m, hexraw.Hex, "[") + if m.byteViews.HexTop != m.byteViews.HexCur { + t.Fatalf("after [ hexTop = %d, hexCur = %d", m.byteViews.HexTop, m.byteViews.HexCur) } - if got := m.hexImg.AddrAt(m.hexCur); got >= 0x10203cb17 { + if got := m.byteViews.HexImg.AddrAt(m.byteViews.HexCur); got >= 0x10203cb17 { t.Fatalf("[ landed at 0x%x, want a section before 0x10203cb17", got) } } @@ -490,13 +502,13 @@ func assertSectionBytesBelowSeparator(t *testing.T, out, section, bytes, lineAdd if !strings.Contains(line, lineAddr) { t.Fatalf("post-separator line %q does not contain aligned address %q", line, lineAddr) } - if idx := strings.Index(line, bytes[:2]); idx <= hexBodyStart(16) { + if idx := strings.Index(line, bytes[:2]); idx <= testHexBodyStart(16) { t.Fatalf("post-separator bytes are not offset on line: %q", line) } } func TestPadBodyRowsClampsAndPads(t *testing.T) { - got := padBodyRows([]string{"abc", "abcdef"}, 4, 3) + got := layout.PadBodyRows([]string{"abc", "abcdef"}, 4, 3) lines := strings.Split(got, "\n") if len(lines) != 3 { t.Fatalf("padded line count = %d, want 3", len(lines)) @@ -509,18 +521,18 @@ func TestPadBodyRowsClampsAndPads(t *testing.T) { } func TestTruncateMiddleKeepsEnds(t *testing.T) { - got := truncateMiddle("/very/long/source/path/main.go", 16) + got := layout.TruncateMiddle("/very/long/source/path/main.go", 16) if got == "/very/long/source/path/main.go" || lipgloss.Width(got) > 16 { - t.Fatalf("truncateMiddle returned %q", got) + t.Fatalf("layout.TruncateMiddle returned %q", got) } if got[:1] != "/" || got[len(got)-5:] != "in.go" { - t.Fatalf("truncateMiddle did not keep useful ends: %q", got) + t.Fatalf("layout.TruncateMiddle did not keep useful ends: %q", got) } } func TestRenderLineRowsActuallyWraps(t *testing.T) { line := "0123456789 abcdefghij klmnopqrst" - rows := renderLineRowsIndented(line, 12, true, 0) + rows := layout.RenderLineRowsIndented(line, 12, true, 0) if len(rows) < 3 { t.Fatalf("wrapped rows = %d, want at least 3: %q", len(rows), rows) } @@ -529,13 +541,13 @@ func TestRenderLineRowsActuallyWraps(t *testing.T) { t.Fatalf("row width = %d, want <= 12: %q", w, row) } } - if got := renderLineRowsIndented(line, 12, false, 0); len(got) != 1 { + if got := layout.RenderLineRowsIndented(line, 12, false, 0); len(got) != 1 { t.Fatalf("non-wrapped rows = %d, want 1", len(got)) } } func TestRenderLineRowsIndentedContinuation(t *testing.T) { - rows := renderLineRowsIndented("addr type very-long-content-that-wraps", 18, true, 6) + rows := layout.RenderLineRowsIndented("addr type very-long-content-that-wraps", 18, true, 6) if len(rows) < 2 { t.Fatalf("rows = %q, want wrapped continuation", rows) } @@ -554,37 +566,42 @@ func TestSearchPopupClickTogglesSwitches(t *testing.T) { m := &Model{ theme: DefaultTheme(), layoutState: layoutState{width: 100, height: 30}, - searchState: searchState{searchActive: true, searchForward: true, searchFromCursor: true}, } - m.searchInput = textinput.New() - modal := m.renderSearchModal() + m.search.Init(textinput.New()) + m.search.Open() + modal := m.search.Render(m.modalContext(), m) left := (m.width - lipgloss.Width(modal)) / 2 top := (m.height - lipgloss.Height(modal)) / 2 // The switch strip lives at content row searchSwitchLine, inside the modal's // border (1) + padding (1,2). Compute the centre x of each segment from the // shared searchSwitches() layout so the test can't drift from the renderer. - rowY := top + 2 + searchSwitchLine - sepW := lipgloss.Width(searchSwitchSep) - centers := make([]int, 0, 3) - pos := 0 - for _, sw := range m.searchSwitches() { - w := lipgloss.Width(sw.label()) - centers = append(centers, left+3+pos+w/2) + rowY := top + 2 + searchmodal.SwitchLine + sepW := lipgloss.Width(searchmodal.SwitchSep) + sw := m.search.Switches() + center := map[string]int{} + pos := 1 // the switch strip is indented one column + for _, s := range sw { + w := lipgloss.Width(s.Label()) + center[s.Name] = left + 3 + pos + w/2 pos += w + sepW } - m.handleSearchPopupClick(centers[0], rowY) - if m.searchMode != searchModeText { - t.Fatalf("mode click set mode = %s, want text", searchModeName(m.searchMode)) + m.handleSearchPopupClick(center["mode"], rowY) + if m.search.Mode() != searchutil.ModeText { + t.Fatalf("mode click set mode = %s, want text", m.search.Mode()) + } + m.handleSearchPopupClick(center["case"], rowY) + if !m.search.CaseSensitive() { + t.Fatal("case click did not toggle case sensitivity") } - m.handleSearchPopupClick(centers[1], rowY) - if m.searchForward { - t.Fatal("direction click did not toggle searchForward") + m.handleSearchPopupClick(center["dir"], rowY) + if m.search.Forward() { + t.Fatal("direction click did not toggle direction") } - m.handleSearchPopupClick(centers[2], rowY) - if m.searchFromCursor { - t.Fatal("origin click did not toggle searchFromCursor") + m.handleSearchPopupClick(center["origin"], rowY) + if m.search.FromCursor() { + t.Fatal("origin click did not toggle origin") } } @@ -599,9 +616,9 @@ func TestWrappedSymbolsKeepAddressGrayOnContinuation(t *testing.T) { }}, }, } - m.symbolsFiltered = []int{0} - m.buildSymbolRows() - rows := m.symbolRows(0, 8) + m.symbols.Filtered = []int{0} + m.symbols.Recompute(m.viewContext()) + rows := m.symbols.RowLines(m.viewContext(), 0) if len(rows) < 2 { t.Fatalf("symbol did not wrap: %q", rows) } @@ -624,19 +641,19 @@ func TestWrappedSymbolsMouseSelectionUsesVisualRows(t *testing.T) { {Name: "second", Addr: 0x2000, Kind: binfile.SymObject}, }}, } - m.symbolsFiltered = []int{0, 1} - m.buildSymbolRows() - firstRows := m.symbolRowHeight(0) + m.symbols.Filtered = []int{0, 1} + m.symbols.Recompute(m.viewContext()) + firstRows := m.symbols.RowHeightFn(m.viewContext())(0) if firstRows < 2 { t.Fatalf("first symbol did not wrap") } m.handleClick(1, 1+2+firstRows-1) - if m.symbolsCur != 0 { - t.Fatalf("click on first continuation selected %d, want 0", m.symbolsCur) + if m.symbols.Cur != 0 { + t.Fatalf("click on first continuation selected %d, want 0", m.symbols.Cur) } m.handleClick(1, 1+2+firstRows) - if m.symbolsCur != 1 { - t.Fatalf("click after wrapped first selected %d, want 1", m.symbolsCur) + if m.symbols.Cur != 1 { + t.Fatalf("click after wrapped first selected %d, want 1", m.symbols.Cur) } } @@ -647,23 +664,24 @@ func TestVisualItemAtRowUsesWrappedHeights(t *testing.T) { } return 1 } - if got, ok := visualItemAtRow(0, 3, 2, heights); !ok || got != 0 { + if got, ok := layout.VisualItemAtRow(0, 3, 2, heights); !ok || got != 0 { t.Fatalf("row 2 maps to %d,%v; want first wrapped item", got, ok) } - if got, ok := visualItemAtRow(0, 3, 3, heights); !ok || got != 1 { + if got, ok := layout.VisualItemAtRow(0, 3, 3, heights); !ok || got != 1 { t.Fatalf("row 3 maps to %d,%v; want second item", got, ok) } } func TestRawClickSkipsSectionSplitRows(t *testing.T) { - m := &Model{layoutState: layoutState{width: 100}, file: &binfile.File{Sections: []binfile.Section{{Name: ".text", Offset: 0, FileSize: 32}}}} + m := &Model{layoutState: layoutState{width: 100, height: 10}, file: &binfile.File{Sections: []binfile.Section{{Name: ".text", Offset: 0, FileSize: 32}}}} data := make([]byte, 64) - cur := m.clickByte(modeRaw, rawBytes(data), 0, 7, hexBodyStart(16), 1, func(pos int) uint64 { return uint64(pos) }) - if cur != 7 { + m.byteViews = hexraw.State{RawData: data, RawCur: 7} + m.byteViews.Click(m.viewContextPtr(), hexraw.Raw, testHexBodyStart(16), 1) + if cur := m.byteViews.RawCur; cur != 7 { t.Fatalf("click on split row changed cursor to %d", cur) } - cur = m.clickByte(modeRaw, rawBytes(data), 0, 7, hexBodyStart(16), 2, func(pos int) uint64 { return uint64(pos) }) - if cur != 0 { + m.byteViews.Click(m.viewContextPtr(), hexraw.Raw, testHexBodyStart(16), 2) + if cur := m.byteViews.RawCur; cur != 0 { t.Fatalf("click on first data row selected %d, want 0", cur) } } @@ -680,7 +698,7 @@ func TestDisasmAnnotationWrapsSeparatelyAndAddressLinks(t *testing.T) { }, } inst := disasm.Inst{Addr: 0x1000, Text: "call 0x2000", Class: disasm.ClassCall, Bytes: []byte{0xe8}} - rows := m.disasmInstRows(inst, 64, false, nil) + rows := m.dasm.InstRows(m.viewContextPtr(), inst, 64, false, nil) if len(rows) < 2 { t.Fatalf("annotation did not wrap into separate rows: %q", rows) } diff --git a/internal/ui/scope/scope.go b/internal/ui/scope/scope.go new file mode 100644 index 0000000..409460c --- /dev/null +++ b/internal/ui/scope/scope.go @@ -0,0 +1,45 @@ +// Package scope names what a search looks through. +// +// It is the one piece of vocabulary the three search surfaces share: the goto +// palette (which searches a scope), the Find seed picker (each seed names the +// scope it would search), and the global value search (which routes a seed's +// scope to a query). Keeping it here lets each of those live in its own package +// without one of them owning a type the others need. +package scope + +// Scope selects the corpus a search runs over. +type Scope uint8 + +const ( + All Scope = iota // symbols + sections + a typed address + Symbols // symbols only + Sections // section names + Strings // printable strings (its own scope — the corpus is large) + Libs // linked libraries + Addr // a raw address (virtual, or physical when toggled) + Count // number of scopes; not a scope itself +) + +// String is the scope's user-facing name. +func (s Scope) String() string { + switch s { + case Symbols: + return "symbols" + case Sections: + return "sections" + case Strings: + return "strings" + case Libs: + return "libraries" + case Addr: + return "address" + default: + return "all" + } +} + +// Next cycles forward through the scopes, wrapping. +func Next(s Scope) Scope { return (s + 1) % Count } + +// Prev cycles backward through the scopes, wrapping. +func Prev(s Scope) Scope { return (s + Count - 1) % Count } diff --git a/internal/ui/scope/scope_test.go b/internal/ui/scope/scope_test.go new file mode 100644 index 0000000..b430e9d --- /dev/null +++ b/internal/ui/scope/scope_test.go @@ -0,0 +1,57 @@ +package scope + +import "testing" + +// TestCountIsPastTheLastScope: Next/Prev use Count as the modulus, so a scope +// added without moving Count would be unreachable by the cycle keys. +func TestCountIsPastTheLastScope(t *testing.T) { + if Count != Addr+1 { + t.Fatalf("Count = %d, want %d (one past the last scope)", Count, Addr+1) + } +} + +func TestEveryScopeHasADistinctName(t *testing.T) { + seen := map[string]Scope{} + for s := Scope(0); s < Count; s++ { + name := s.String() + if name == "" { + t.Errorf("scope %d has no name", s) + } + if prev, dup := seen[name]; dup { + t.Errorf("scopes %d and %d share the name %q", prev, s, name) + } + seen[name] = s + } +} + +func TestNextCyclesThroughEveryScope(t *testing.T) { + s := All + visited := map[Scope]bool{s: true} + for range int(Count) - 1 { + s = Next(s) + if visited[s] { + t.Fatalf("Next revisited %v before covering every scope", s) + } + visited[s] = true + } + if s = Next(s); s != All { + t.Errorf("Next from the last scope = %v, want All (wrap)", s) + } + if len(visited) != int(Count) { + t.Errorf("Next visited %d scopes, want %d", len(visited), Count) + } +} + +func TestPrevIsNextInverted(t *testing.T) { + for s := Scope(0); s < Count; s++ { + if got := Prev(Next(s)); got != s { + t.Errorf("Prev(Next(%v)) = %v", s, got) + } + if got := Next(Prev(s)); got != s { + t.Errorf("Next(Prev(%v)) = %v", s, got) + } + } + if got := Prev(All); got != Addr { + t.Errorf("Prev(All) = %v, want Addr (wrap backwards)", got) + } +} diff --git a/internal/ui/search.go b/internal/ui/search.go index 5a0e1ac..9c6f540 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -10,72 +10,37 @@ import ( tea "charm.land/bubbletea/v2" searchutil "github.com/rabarbra/exex/internal/bytesearch" + searchmodal "github.com/rabarbra/exex/internal/ui/modals/search" + "github.com/rabarbra/exex/internal/ui/views/hexraw" ) -type searchMode = searchutil.Mode - -const ( - searchModeAuto = searchutil.ModeAuto - searchModeText = searchutil.ModeText - searchModeHex = searchutil.ModeHex -) - -func searchModeName(mode searchMode) string { - return mode.String() -} - -func (m *Model) cycleSearchMode() { - m.searchMode = searchutil.NextMode(m.searchMode) -} - -// searchSwitch is one clickable toggle in the search popup: a dim name and the -// current value rendered as a pill ("name ⟦value⟧"). -type searchSwitch struct { - name string - value string - toggle func() -} - -// label is the plain "name ⟦value⟧" text; its width drives both the render and -// the mouse hit-test so they can't drift. -func (s searchSwitch) label() string { return s.name + " ⟦" + s.value + "⟧" } - -// searchSwitchSep separates the switch segments; searchSwitchLine is the 0-based -// content row the switch strip occupies inside the modal (header, hint, blank, -// input, blank, switches). -const ( - searchSwitchSep = " " - searchSwitchLine = 5 -) - -// searchSwitches returns the mode / direction / origin toggles. The render and -// the mouse hit-test both build from this, so they can't drift. -func (m *Model) searchSwitches() []searchSwitch { - dir := "→ forward" - if !m.searchForward { - dir = "← backward" - } - origin := "cursor" - if !m.searchFromCursor { - if m.searchForward { - origin = "start" - } else { - origin = "end" +// openSearch opens the in-view search prompt. +func (m *Model) openSearch() { m.search.Open() } + +// SearchCaseChanged drops the disasm search cache, whose hits were computed +// under the previous case setting. It satisfies search.Host. +func (m *Model) SearchCaseChanged() { m.searchResults.Reset("") } + +// SearchHint describes what the active view searches. It satisfies search.Host. +func (m *Model) SearchHint() string { return m.current().searchHint() } + +// SubmitSearch runs the typed query in the active view, re-pinning the byte views +// when the search moved the cursor. It satisfies search.Host. +func (m *Model) SubmitSearch(query string, o searchmodal.Options) tea.Cmd { + before := m.activeCursorState() + m.searchQuery = query + inclusive := o.FromCursor + cmd := m.runSearchWithOrigin(o.Forward, inclusive, o.FromCursor) + if before != m.activeCursorState() { + m.viewportDetached = false + switch m.mode { + case modeHex: + m.byteViews.PinCurrentSectionStart(m.viewContextPtr(), hexraw.Hex) + case modeRaw: + m.byteViews.PinCurrentSectionStart(m.viewContextPtr(), hexraw.Raw) } } - return []searchSwitch{ - {"mode", searchModeName(m.searchMode), m.cycleSearchMode}, - {"dir", dir, func() { m.searchForward = !m.searchForward }}, - {"origin", origin, func() { m.searchFromCursor = !m.searchFromCursor }}, - } -} - -// openSearch opens the search prompt. Repeat search still uses searchQuery via -// n/N, but each new prompt starts empty so stale input is not accidentally reused. -func (m *Model) openSearch() { - m.searchActive = true - m.searchInput.SetValue("") - m.searchInput.Focus() + return cmd } // runSearch finds the next/previous match for the current query in the active @@ -85,36 +50,16 @@ func (m *Model) runSearch(forward, inclusive bool) tea.Cmd { return m.runSearchWithOrigin(forward, inclusive, true) } -func (m *Model) runSearchFromPrompt() tea.Cmd { - inclusive := true - if !m.searchFromCursor { - inclusive = false - } - return m.runSearchWithOrigin(m.searchForward, inclusive, m.searchFromCursor) -} - func (m *Model) runSearchWithOrigin(forward, inclusive bool, fromCursor bool) tea.Cmd { if m.searchQuery == "" { m.setStatus("no search query", true) return nil } - switch m.mode { - case modeDisasm: - return m.runDisasmSearch(forward, inclusive, fromCursor) - case modeHex: - m.runHexSearch(forward, inclusive, fromCursor) - case modeRaw: - m.runRawSearch(forward, inclusive, fromCursor) - case modeSources: - m.runSourcesSearch(forward, inclusive) - default: - m.setStatus("search isn't available in this view", true) - } - return nil + return m.current().runSearch(forward, inclusive, fromCursor) } func (m *Model) runDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { - if m.sourceFirst && m.srcFile != "" { + if m.dasm.SourceFirst && m.dasm.SrcFile != "" { m.searchInSourceFile(forward, inclusive) return nil } @@ -122,29 +67,37 @@ func (m *Model) runDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { } func (m *Model) runHexSearch(forward, inclusive, fromCursor bool) { - m.ensureHex() - start := m.hexCur + ctx := m.viewContext() + data, cur, ok := m.byteViews.Data(&ctx, hexraw.Hex) + if !ok { + return + } + start := cur if !fromCursor { if forward { start = -1 } else { - start = m.hexImg.Len() - 1 + start = data.Len() - 1 } } - m.hexCur = m.searchBytesAt(m.hexImg, start, forward, inclusive) + m.byteViews.SetCursor(hexraw.Hex, m.searchBytesAt(data, start, forward, inclusive)) } func (m *Model) runRawSearch(forward, inclusive, fromCursor bool) { - m.ensureRaw() - start := m.rawCur + ctx := m.viewContext() + data, cur, ok := m.byteViews.Data(&ctx, hexraw.Raw) + if !ok { + return + } + start := cur if !fromCursor { if forward { start = -1 } else { - start = len(m.rawData) - 1 + start = data.Len() - 1 } } - m.rawCur = m.searchBytesAt(rawBytes(m.rawData), start, forward, inclusive) + m.byteViews.SetCursor(hexraw.Raw, m.searchBytesAt(data, start, forward, inclusive)) } func (m *Model) runSourcesSearch(forward, inclusive bool) { @@ -171,11 +124,14 @@ func (m *Model) stopDisasmSearch() { } func (m *Model) searchBytesAt(data byteSource, cur int, forward, inclusive bool) int { - pat := searchutil.ParsePattern(m.searchQuery, m.searchMode) + pat := searchutil.ParsePattern(m.searchQuery, m.search.Mode()) if len(pat) == 0 { m.setStatus("empty search pattern", true) return cur } + // Case-insensitive only for text patterns (folding a hex byte pattern would + // wrongly match unrelated letter values). + fold := !m.search.CaseSensitive() && searchutil.IsTextPattern(m.searchQuery, m.search.Mode()) start := cur if !inclusive { if forward { @@ -184,7 +140,7 @@ func (m *Model) searchBytesAt(data byteSource, cur int, forward, inclusive bool) start-- } } - if i := findBytesSrc(data, pat, start, forward); i >= 0 { + if i := findBytesSrc(data, pat, start, forward, fold); i >= 0 { m.setStatus(fmt.Sprintf("match at offset +0x%x", i), false) return i } @@ -198,7 +154,7 @@ func (m *Model) searchBytesAt(data byteSource, cur int, forward, inclusive bool) // same speed as a flat []byte and never allocates. Matches are within a region // (sections) — the meaningful scope; a pattern straddling a section boundary in // the flattened image is not matched. -func findBytesSrc(data byteSource, pat []byte, start int, forward bool) int { +func findBytesSrc(data byteSource, pat []byte, start int, forward, fold bool) int { n := data.Len() if len(pat) == 0 || len(pat) > n { return -1 @@ -214,7 +170,7 @@ func findBytesSrc(data byteSource, pat []byte, start int, forward bool) int { } from := max(start-r.Off, 0) if from <= len(r.B)-len(pat) { - if j := searchutil.FindBytes(r.B, pat, from, true); j >= 0 { + if j := searchutil.FindBytesFold(r.B, pat, from, true, fold); j >= 0 { return r.Off + j } } @@ -231,7 +187,7 @@ func findBytesSrc(data byteSource, pat []byte, start int, forward bool) int { } hi := min(start-r.Off, len(r.B)-len(pat)) // greatest local start to consider if hi >= 0 { - if j := searchutil.FindBytes(r.B, pat, hi, false); j >= 0 { + if j := searchutil.FindBytesFold(r.B, pat, hi, false, fold); j >= 0 { return r.Off + j } } diff --git a/internal/ui/search_disasm.go b/internal/ui/search_disasm.go index 21f2aaf..3d1eed9 100644 --- a/internal/ui/search_disasm.go +++ b/internal/ui/search_disasm.go @@ -7,7 +7,7 @@ package ui import ( "fmt" - "sort" + "slices" "strconv" "strings" "sync" @@ -16,6 +16,8 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/explorer" + "github.com/rabarbra/exex/internal/ui/layout" ) type disasmSearchHit struct { @@ -27,17 +29,18 @@ type disasmSearchHit struct { } type disasmSearchStep struct { - file *binfile.File - seq int - label string - query string - forward bool - inclusive bool - logical int - total int - chunk int - base int - cancel <-chan struct{} + file *binfile.File + seq int + label string + query string + caseSensitive bool + forward bool + inclusive bool + logical int + total int + chunk int + base int + cancel <-chan struct{} } type disasmSearchProgressMsg struct { @@ -53,149 +56,132 @@ type disasmSearchProgressMsg struct { status string } -type disasmSearchCache struct { - query string - hits []disasmSearchHit - forwardExhausted bool - backwardExhausted bool - scannedLo int - scannedHi int - overflow bool +const disasmSearchLead = 1 << 10 + +// directionalHitBuffer keeps only the nearest hits in the direction being +// searched. Backward decoding still arrives in ascending address order, so it +// uses a ring to retain the latest addresses without shifting on every match. +type directionalHitBuffer struct { + hits []disasmSearchHit + limit int + forward bool + next int + overflow bool } -const disasmSearchCacheCap = 100 +func (b *directionalHitBuffer) add(hit disasmSearchHit) { + if len(b.hits) < b.limit { + b.hits = append(b.hits, hit) + return + } + b.overflow = true + if b.forward { + return + } + b.hits[b.next] = hit + b.next = (b.next + 1) % b.limit +} -const ( - searchCursorAtMatch = iota - searchCursorAfterEnd - searchCursorBeforeStart -) +func (b *directionalHitBuffer) ordered() []disasmSearchHit { + if b.forward { + return b.hits + } + out := make([]disasmSearchHit, len(b.hits)) + if !b.overflow { + for i := range b.hits { + out[i] = b.hits[len(b.hits)-1-i] + } + return out + } + for i := range b.hits { + j := (b.next - 1 - i + len(b.hits)) % len(b.hits) + out[i] = b.hits[j] + } + return out +} -func (m *Model) resetDisasmSearchCache(query string) { - m.searchResults = disasmSearchCache{query: query, scannedLo: -1} - m.searchCursorMode = searchCursorAtMatch - m.searchCursorAddr = 0 +func appendDirectionalHits(dst, src []disasmSearchHit, limit int) []disasmSearchHit { + if len(dst) >= limit { + return dst + } + return append(dst, src[:min(len(src), limit-len(dst))]...) } +// searchCursorMode values mirror explorer.CursorMode; the shell tracks where the +// search cursor sits so the cache can answer "the next hit" after running off an +// end. +const ( + searchCursorAtMatch = explorer.CursorAtMatch + searchCursorAfterEnd = explorer.CursorAfterEnd + searchCursorBeforeStart = explorer.CursorBeforeStart +) + +// ensureDisasmSearchCache keeps the cache aligned with the live query; the +// cache itself lives in internal/explorer. func (m *Model) ensureDisasmSearchCache() { - if m.searchResults.query != m.searchQuery { - m.resetDisasmSearchCache(m.searchQuery) + if m.searchResults.EnsureQuery(m.searchQuery) { + m.searchCursorMode = searchCursorAtMatch + m.searchCursorAddr = 0 } } +// cacheDisasmSearchHits stores the addresses and text of fresh hits. The decoded +// windows that found them are deliberately dropped (see explorer.SearchHit). func (m *Model) cacheDisasmSearchHits(hits []disasmSearchHit, forward bool) { if len(hits) == 0 { return } m.ensureDisasmSearchCache() - sort.Slice(hits, func(i, j int) bool { return hits[i].addr < hits[j].addr }) - for _, hit := range hits { - i := sort.Search(len(m.searchResults.hits), func(i int) bool { return m.searchResults.hits[i].addr >= hit.addr }) - if i < len(m.searchResults.hits) && m.searchResults.hits[i].addr == hit.addr { - continue - } - if len(m.searchResults.hits) >= disasmSearchCacheCap { - m.searchResults.overflow = true - } - m.searchResults.hits = append(m.searchResults.hits, disasmSearchHit{}) - copy(m.searchResults.hits[i+1:], m.searchResults.hits[i:]) - m.searchResults.hits[i] = disasmSearchHit{addr: hit.addr, text: hit.text} - } - if len(m.searchResults.hits) > disasmSearchCacheCap { - if forward { - m.searchResults.hits = m.searchResults.hits[len(m.searchResults.hits)-disasmSearchCacheCap:] - } else { - m.searchResults.hits = m.searchResults.hits[:disasmSearchCacheCap] - } + cached := make([]explorer.SearchHit, len(hits)) + for i, h := range hits { + cached[i] = explorer.SearchHit{Addr: h.addr, Text: h.text} } + m.searchResults.Add(cached, forward) } func (m *Model) noteDisasmSearchCoverage(lo, hi int) { m.ensureDisasmSearchCache() - if lo < 0 { - lo = 0 - } - if hi < lo { - hi = lo - } - if m.searchResults.scannedLo < 0 || lo < m.searchResults.scannedLo { - m.searchResults.scannedLo = lo - } - if hi > m.searchResults.scannedHi { - m.searchResults.scannedHi = hi - } + m.searchResults.NoteCoverage(lo, hi) } func (m *Model) disasmSearchCacheComplete() bool { - img := m.file.ExecImage() - return !m.searchResults.overflow && m.searchResults.scannedLo == 0 && m.searchResults.scannedHi >= img.Len() + return m.searchResults.Complete(m.file.ExecImage().Len()) } -func (m *Model) cachedDisasmSearchHit(forward, inclusive bool) (disasmSearchHit, bool) { +// cachedDisasmSearchHit answers a repeat search from the cache when it can. +func (m *Model) cachedDisasmSearchHit(forward, inclusive bool) (explorer.SearchHit, bool) { m.ensureDisasmSearchCache() - if len(m.searchResults.hits) == 0 || len(m.disasmInst) == 0 { - return disasmSearchHit{}, false - } - if !forward && m.searchCursorMode == searchCursorAfterEnd { - return m.searchResults.hits[len(m.searchResults.hits)-1], true + if len(m.dasm.Inst) == 0 { + return explorer.SearchHit{}, false } - if forward && m.searchCursorMode == searchCursorBeforeStart { - return m.searchResults.hits[0], true - } - cur := m.disasmInst[m.disasmCur].Addr + cur := m.dasm.Inst[m.dasm.Cur].Addr if m.searchCursorAddr != 0 { cur = m.searchCursorAddr } - if forward { - for _, hit := range m.searchResults.hits { - if (!inclusive && hit.addr <= cur) || (inclusive && hit.addr < cur) { - continue - } - return hit, true - } - return disasmSearchHit{}, false - } - for i := len(m.searchResults.hits) - 1; i >= 0; i-- { - hit := m.searchResults.hits[i] - if (!inclusive && hit.addr >= cur) || (inclusive && hit.addr > cur) { - continue - } - return hit, true - } - return disasmSearchHit{}, false + return m.searchResults.Next(cur, m.searchCursorMode, forward, inclusive) } func (m *Model) cachedDisasmSearchBoundary(forward bool) (uint64, bool) { m.ensureDisasmSearchCache() - if len(m.searchResults.hits) == 0 { - return 0, false - } - if forward { - return m.searchResults.hits[len(m.searchResults.hits)-1].addr, true - } - return m.searchResults.hits[0].addr, true + return m.searchResults.Boundary(forward) } func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { m.setStatus("no disassembly loaded", true) return nil } m.ensureDisasmSearchCache() if fromCursor { if hit, ok := m.cachedDisasmSearchHit(forward, inclusive); ok { - m.loadDisasmAt(hit.addr) + m.loadDisasmAt(hit.Addr) m.searchCursorMode = searchCursorAtMatch - m.searchCursorAddr = hit.addr - m.setStatus("match: "+strings.TrimSpace(hit.text), false) - return m.prefetchDisasmAroundCmd(hit.addr) + m.searchCursorAddr = hit.Addr + m.setStatus("match: "+strings.TrimSpace(hit.Text), false) + return m.prefetchDisasmAroundCmd(hit.Addr) } } - if fromCursor && forward && m.searchResults.forwardExhausted { - m.setStatus("not found: "+m.searchQuery, true) - return nil - } - if fromCursor && !forward && m.searchResults.backwardExhausted { + if fromCursor && m.searchResults.Exhausted(forward) { m.setStatus("not found: "+m.searchQuery, true) return nil } @@ -212,7 +198,7 @@ func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { return m.prefetchDisasmAroundCmd(hit.addr) } img := m.file.ExecImage() - cur := m.disasmInst[m.disasmCur] + cur := m.dasm.Inst[m.dasm.Cur] pos, ok := img.PosForAddr(cur.Addr) if !ok { m.setStatus("current disasm address is not in executable image", true) @@ -225,16 +211,17 @@ func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { done := make(chan struct{}) m.searchCancel = done step := disasmSearchStep{ - file: m.file, - seq: m.searchSeq, - label: m.searchQuery, - query: canonicalSearchQuery(m.searchQuery), - forward: forward, - inclusive: inclusive, - total: img.Len(), - chunk: m.disasmSearchChunkBytes(), - base: pos, - cancel: done, + file: m.file, + seq: m.searchSeq, + label: m.searchQuery, + query: canonicalSearchQuery(m.searchQuery), + caseSensitive: m.search.CaseSensitive(), + forward: forward, + inclusive: inclusive, + total: img.Len(), + chunk: m.disasmSearchChunkBytes(), + base: pos, + cancel: done, } if !fromCursor { if forward { @@ -265,15 +252,24 @@ func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { } } m.setStatus(m.disasmSearchStatus(step), false) - return m.searchDisasmStepCmd(step) + return m.backgroundCmd(m.searchDisasmStepCmd(step)) +} + +// nameMatches compares a search query to a symbol name, honouring case +// sensitivity (the fast path is an exact-name match). +func nameMatches(q, name string, caseSensitive bool) bool { + if caseSensitive { + return q == name + } + return strings.EqualFold(q, name) } func (m *Model) searchDisasmSymbolFastPath(forward, inclusive, fromCursor bool) (disasmSearchHit, bool) { q := strings.TrimSpace(m.searchQuery) - if q == "" || len(m.disasmInst) == 0 { + if q == "" || len(m.dasm.Inst) == 0 { return disasmSearchHit{}, false } - cur := m.disasmInst[m.disasmCur].Addr + cur := m.dasm.Inst[m.dasm.Cur].Addr if !fromCursor { if forward { cur = 0 @@ -290,7 +286,10 @@ func (m *Model) searchDisasmSymbolFastPath(forward, inclusive, fromCursor bool) } // Cheap, allocation-free name match first (Display is Demangled-or-Name, // so checking both covers it); only then the binary-search membership test. - if !strings.EqualFold(q, sym.Name) && !(sym.Demangled != "" && strings.EqualFold(q, sym.Demangled)) { + // Honour case sensitivity so a sensitive search doesn't fast-path to a + // wrong-case symbol. + if !nameMatches(q, sym.Name, m.search.CaseSensitive()) && + !(sym.Demangled != "" && nameMatches(q, sym.Demangled, m.search.CaseSensitive())) { continue } if _, ok := img.PosForAddr(sym.Addr); !ok { @@ -372,25 +371,43 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { } } matchText := func(s string) bool { + if step.caseSensitive { + return strings.Contains(s, step.label) // exact, case-sensitive + } if queryASCII { - return containsFold(s, query) + return layout.ContainsFold(s, query) } return strings.Contains(strings.ToLower(s), query) } + var symbolStarts []uint64 + for _, sym := range file.Symbols { + if sym.Addr != 0 && matchText(sym.Display()) { + symbolStarts = append(symbolStarts, sym.Addr) + } + } + slices.Sort(symbolStarts) + symbolStarts = slices.Compact(symbolStarts) match := func(instText string, addr uint64) bool { if matchText(instText) { return true } - if sym, ok := file.SymbolAt(addr); ok && sym.Addr == addr && matchText(sym.Display()) { - return true - } - return false + _, ok := slices.BinarySearch(symbolStarts, addr) + return ok } type chunkResult struct { - order int - win binfile.Window - insts []disasm.Inst - hits []disasmSearchHit + hits []disasmSearchHit + } + const hitLimit = explorer.CacheCap + 1 // one extra makes SearchCache record overflow + hydrate := func(hit disasmSearchHit) disasmSearchHit { + win, ok := img.WindowContaining(hit.addr, step.chunk, step.chunk/4) + if !ok { + return hit + } + insts := svc.DecodeWindow(win) + if idx, ok := disasm.IndexForAddr(insts, hit.addr); ok { + hit.win, hit.insts, hit.idx = win, insts, idx + } + return hit } return func() tea.Msg { if scanCancelled(step.cancel) { @@ -427,35 +444,27 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { if scanCancelled(step.cancel) { return } - insts := svc.DecodeWindow(win) - results[i] = chunkResult{order: i, win: win, insts: insts} - startPos := step.logical - if i > 0 { - startPos = win.Start - } - for j, inst := range insts { + buf := directionalHitBuffer{forward: true, limit: hitLimit} + svc.DecodeRangeFunc(win.Start, win.End-win.Start, disasmSearchLead, func(inst disasm.Inst) bool { if scanCancelled(step.cancel) { - return - } - instPos, ok := img.PosForAddr(inst.Addr) - if !ok || instPos < startPos { - continue + return false } if match(inst.Text, inst.Addr) { - results[i].hits = append(results[i].hits, disasmSearchHit{win: win, insts: insts, idx: j, addr: inst.Addr, text: inst.Text}) + buf.add(disasmSearchHit{addr: inst.Addr, text: inst.Text}) } - } + return true + }) + results[i].hits = buf.ordered() }(i, win) } wg.Wait() - var found []disasmSearchHit + found := make([]disasmSearchHit, 0, hitLimit) for _, res := range results { - if len(res.hits) > 0 { - found = append(found, res.hits...) - } + found = appendDirectionalHits(found, res.hits, hitLimit) } if len(found) > 0 { - return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: step.logical, scannedHi: logical, done: true} + hit := hydrate(found[0]) + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &hit, found: found, scannedLo: step.logical, scannedHi: logical, done: true} } next := step next.logical = logical @@ -488,36 +497,27 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { if scanCancelled(step.cancel) { return } - insts := svc.DecodeWindow(win) - results[i] = chunkResult{order: i, win: win, insts: insts} - endPos := step.logical - if i > 0 { - endPos = win.End - } - for j := len(insts) - 1; j >= 0; j-- { + buf := directionalHitBuffer{forward: false, limit: hitLimit} + svc.DecodeRangeFunc(win.Start, win.End-win.Start, disasmSearchLead, func(inst disasm.Inst) bool { if scanCancelled(step.cancel) { - return - } - inst := insts[j] - instPos, ok := img.PosForAddr(inst.Addr) - if !ok || instPos >= endPos { - continue + return false } if match(inst.Text, inst.Addr) { - results[i].hits = append(results[i].hits, disasmSearchHit{win: win, insts: insts, idx: j, addr: inst.Addr, text: inst.Text}) + buf.add(disasmSearchHit{addr: inst.Addr, text: inst.Text}) } - } + return true + }) + results[i].hits = buf.ordered() }(i, win) } wg.Wait() - var found []disasmSearchHit + found := make([]disasmSearchHit, 0, hitLimit) for _, res := range results { - if len(res.hits) > 0 { - found = append(found, res.hits...) - } + found = appendDirectionalHits(found, res.hits, hitLimit) } if len(found) > 0 { - return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: logical, scannedHi: step.logical, done: true} + hit := hydrate(found[0]) + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &hit, found: found, scannedLo: logical, scannedHi: step.logical, done: true} } next := step next.logical = logical diff --git a/internal/ui/search_disasm_test.go b/internal/ui/search_disasm_test.go new file mode 100644 index 0000000..857f6b3 --- /dev/null +++ b/internal/ui/search_disasm_test.go @@ -0,0 +1,44 @@ +package ui + +import "testing" + +func TestDirectionalHitBufferBoundsForwardHits(t *testing.T) { + b := directionalHitBuffer{forward: true, limit: 4} + for addr := uint64(1); addr <= 6; addr++ { + b.add(disasmSearchHit{addr: addr}) + } + got := b.ordered() + if len(got) != 4 { + t.Fatalf("len = %d, want 4", len(got)) + } + for i, want := range []uint64{1, 2, 3, 4} { + if got[i].addr != want { + t.Fatalf("hit %d = %d, want %d", i, got[i].addr, want) + } + } +} + +func TestDirectionalHitBufferBoundsBackwardHits(t *testing.T) { + b := directionalHitBuffer{forward: false, limit: 4} + for addr := uint64(1); addr <= 6; addr++ { + b.add(disasmSearchHit{addr: addr}) + } + got := b.ordered() + if len(got) != 4 { + t.Fatalf("len = %d, want 4", len(got)) + } + for i, want := range []uint64{6, 5, 4, 3} { + if got[i].addr != want { + t.Fatalf("hit %d = %d, want %d", i, got[i].addr, want) + } + } +} + +func TestAppendDirectionalHitsNeverExceedsLimit(t *testing.T) { + dst := []disasmSearchHit{{addr: 1}, {addr: 2}} + src := []disasmSearchHit{{addr: 3}, {addr: 4}, {addr: 5}} + got := appendDirectionalHits(dst, src, 4) + if len(got) != 4 || got[3].addr != 4 { + t.Fatalf("got %+v, want four hits ending at address 4", got) + } +} diff --git a/internal/ui/search_perf_bench_test.go b/internal/ui/search_perf_bench_test.go new file mode 100644 index 0000000..e8e9f6d --- /dev/null +++ b/internal/ui/search_perf_bench_test.go @@ -0,0 +1,53 @@ +package ui + +import ( + "os" + "testing" + + "github.com/rabarbra/exex/internal/binfile" +) + +func benchmarkDisasmSearchStep(b *testing.B, query string) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + f, err := binfile.Open(path) + if err != nil { + b.Fatal(err) + } + defer f.Close() + m, err := New(f) + if err != nil { + b.Fatal(err) + } + img := f.ExecImage() + step := disasmSearchStep{ + file: f, + seq: 1, + label: query, + query: canonicalSearchQuery(query), + forward: true, + total: img.Len(), + chunk: m.disasmSearchChunkBytes(), + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + msg, ok := m.searchDisasmStepCmd(step)().(disasmSearchProgressMsg) + if !ok { + b.Fatal("unexpected search message") + } + if query == "add" && len(msg.found) == 0 { + b.Fatal("common query returned no hits") + } + } +} + +func BenchmarkDisasmSearchCommon(b *testing.B) { + benchmarkDisasmSearchStep(b, "add") +} + +func BenchmarkDisasmSearchMissing(b *testing.B) { + benchmarkDisasmSearchStep(b, "definitely-not-present-search-token") +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index a6167e7..a968230 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -4,124 +4,34 @@ package ui // view-background toggle, default wrap and the startup default view. Changes // apply immediately; Enter persists them to the config file (preserving the rest // of it), or warns and keeps them for the session when the file isn't writable. +// +// The overlay itself lives in internal/ui/modals/settings. What stays here is +// what a change *means*: cycling a field rewrites m.cfg and then reaches across +// the shell — rebuilding the theme, re-sorting a view, dropping row caches. That +// blast radius is the shell's, so the overlay asks for it through settings.Host. import ( "fmt" "strconv" - "strings" - - tea "charm.land/bubbletea/v2" "github.com/rabarbra/exex/internal/config" "github.com/rabarbra/exex/internal/syntax" - "github.com/rabarbra/exex/internal/theme" + settingsmodal "github.com/rabarbra/exex/internal/ui/modals/settings" + "github.com/rabarbra/exex/internal/ui/views/hexraw" ) -const settingsFieldCount = 16 - -// settingsMeta describes one setting for display: which group it belongs to (so -// the modal can draw section headers), its label and a one-line explanation. The -// slice order matches the settingsCur / cycleSetting case index, and groups are -// contiguous so a header is drawn once per block. -type settingsMeta struct { - group, label, desc string -} - -var settingsMetas = [settingsFieldCount]settingsMeta{ - {"Appearance", "Theme", "colour palette for the whole UI"}, - {"Appearance", "Panel background", "solid fill behind the view panels"}, - {"Appearance", "Wrap long lines", "soft-wrap rows wider than the window"}, - {"Startup", "Open in view", "the view shown when a file loads"}, - {"Startup", "Disasm target", "where Disasm lands: entry/main/start/text/lowest"}, - {"Lists & trees", "Symbols as tree", "group symbols by their source path"}, - {"Lists & trees", "Sources as tree", "nest source files into folders"}, - {"Lists & trees", "Libraries as tree", "group libraries by directory"}, - {"Lists & trees", "Start collapsed", "open trees folded to the top level"}, - {"Symbols & names", "Abbreviate args", "shorten long demangled signatures"}, - {"Symbols & names", "Demangle symbols", "show foo::bar() vs raw _ZN3foo…"}, - {"Disassembly", "Show raw bytes", "the machine-code byte column"}, - {"Disassembly", "Show annotations", "inline target, reloc & string notes"}, - {"Disassembly", "Byte spacing", "space-separated vs packed bytes"}, - {"Addresses & hex", "Address width", "narrow 64-bit addrs when the top half is 0"}, - {"Addresses & hex", "Hex bytes / row", "bytes per row in the Hex & Raw views"}, -} - -// settingsDisasmTargets is the cycle for the "Disasm target" setting. -var settingsDisasmTargets = []string{"entry", "main", "start", "text", "lowest"} - -// settingsGroupLead reports whether field i begins a new group (so its header -// row is drawn before it). -func settingsGroupLead(i int) bool { - return i == 0 || settingsMetas[i].group != settingsMetas[i-1].group -} - -// settingsRowHeight is the rendered height of field i for the scroll geometry: -// the value row, plus a header line when it leads a group, plus a blank -// separator before every group but the first. -func settingsRowHeight(i int) int { - h := 1 - if settingsGroupLead(i) { - h++ // group header - if i != 0 { - h++ // blank separator above the group - } - } - return h -} - -// settingsViewNames is the cycle for the "default view" setting. -var settingsViewNames = []string{ - "info", "sections", "symbols", "disasm", "hex", "raw", "strings", "libs", "sources", -} - -// settingsThemeList is the cycle for the "theme" setting: the built-in names -// first, then every Chroma style. -func settingsThemeList() []string { - out := []string{defaultThemeName, "dark", "solarized-dark", "solarized-light"} - seen := map[string]bool{} - for _, n := range out { - seen[n] = true - } - for _, n := range theme.Names() { - if !seen[n] { - seen[n] = true - out = append(out, n) - } - } - return out -} - -func (m *Model) openSettings() { - m.settingsActive = true - m.settingsCur = 0 - m.settingsTop = 0 -} - -func (m *Model) closeSettings() { m.settingsActive = false } - -func (m *Model) updateSettings(key string) (tea.Model, tea.Cmd) { - switch key { - case "esc", ",": - m.closeSettings() - case "up", "k": - m.settingsCur = (m.settingsCur + settingsFieldCount - 1) % settingsFieldCount - case "down", "j", "tab": - m.settingsCur = (m.settingsCur + 1) % settingsFieldCount - case "left", "h": - m.cycleSetting(-1) - case "right", "l", " ": - m.cycleSetting(1) - case "enter": - m.persistSettings() - } - return m, nil -} +func (m *Model) openSettings() { m.settings.Open() } -func (m *Model) cycleSetting(dir int) { - switch m.settingsCur { +// CycleSetting steps field i by dir and applies the change. It satisfies +// settings.Host; the index is the position in settings.Metas. +func (m *Model) CycleSetting(i, dir int) { + // Several settings below are baked into the cached view styles; rebuild it + // from the post-change values on the next frame. + m.viewStylesCache = nil + switch i { case 0: - list := settingsThemeList() - m.cfg.Theme = list[cycleIndex(list, m.cfg.Theme, dir)] + list := settingsmodal.ThemeList(defaultThemeName) + m.cfg.Theme = list[settingsmodal.CycleIndex(list, m.cfg.Theme, dir)] m.applyThemeChange() case 1: m.cfg.Behavior.Background = !m.cfg.Behavior.Background @@ -131,37 +41,35 @@ func (m *Model) cycleSetting(dir int) { m.wrap = m.cfg.Behavior.DefaultWrap m.clearAllViewCaches() case 3: - m.cfg.Behavior.DefaultView = settingsViewNames[cycleIndex(settingsViewNames, m.cfg.Behavior.DefaultView, dir)] + m.cfg.Behavior.DefaultView = settingsmodal.ViewNames[settingsmodal.CycleIndex(settingsmodal.ViewNames, m.cfg.Behavior.DefaultView, dir)] case 4: - t := settingsDisasmTargets[cycleIndex(settingsDisasmTargets, m.cfg.Behavior.DefaultDisasmTarget, dir)] + t := settingsmodal.DisasmTargets[settingsmodal.CycleIndex(settingsmodal.DisasmTargets, m.cfg.Behavior.DefaultDisasmTarget, dir)] m.cfg.Behavior.DefaultDisasmTarget = t m.disasmTarget = t // future default landings / redirects use the new strategy case 5: m.cfg.Behavior.TreeSymbols = !m.cfg.Behavior.TreeSymbols - m.symbolsTree = m.cfg.Behavior.TreeSymbols - m.recomputeSymbols() + m.symbols.Tree = m.cfg.Behavior.TreeSymbols + m.symbols.Recompute(m.viewContext()) case 6: m.cfg.Behavior.TreeSources = !m.cfg.Behavior.TreeSources - m.sourcesTree = m.cfg.Behavior.TreeSources - if m.sourcesFiles != nil { - m.recomputeSourceFiles() + m.sources.Tree = m.cfg.Behavior.TreeSources + if m.sources.Files != nil { + m.sources.Recompute(m.viewContext()) } case 7: m.cfg.Behavior.TreeLibs = !m.cfg.Behavior.TreeLibs - m.libsTree = m.cfg.Behavior.TreeLibs - m.buildLibRows() + m.libs.Tree = m.cfg.Behavior.TreeLibs + m.libs.BuildRows(m.viewContext()) case 8: m.cfg.Behavior.TreeCollapsed = !m.cfg.Behavior.TreeCollapsed m.treeCollapseDefault = m.cfg.Behavior.TreeCollapsed // Apply live to whichever trees are currently shown. - m.setAllSymbolsCollapsed(m.treeCollapseDefault) - m.setAllSourcesCollapsed(m.treeCollapseDefault) - m.setAllLibsCollapsed(m.treeCollapseDefault) + m.symbols.SetAllCollapsed(m.treeCollapseDefault) + m.sources.SetAllCollapsed(m.viewContext(), m.treeCollapseDefault) + m.libs.SetAllCollapsed(m.viewContext(), m.treeCollapseDefault) case 9: m.cfg.Behavior.AbbrevArgs = !m.cfg.Behavior.AbbrevArgs - m.symbolsAbbrev = m.cfg.Behavior.AbbrevArgs - m.symbolsAbbrevExcept = nil - m.clearSymbolCaches() + m.symbols.SetAbbrevAll(m.cfg.Behavior.AbbrevArgs) m.clearSymbolNameCaches() case 10: m.toggleDemangle() // flips cfg.Behavior.NoDemangle and re-applies/clears live @@ -183,48 +91,21 @@ func (m *Model) cycleSetting(dir int) { m.clearDisasmDisplayCaches() m.viewDirty = true case 15: - m.cfg.Behavior.HexBytesPerRow = cycleHexBytesPerRow(m.cfg.Behavior.HexBytesPerRow, dir) + m.cfg.Behavior.HexBytesPerRow = settingsmodal.CycleHexBytesPerRow(m.cfg.Behavior.HexBytesPerRow, dir) // Re-snap the scroll anchors to the new row width and redraw (Hex/Raw render // uncached, so nothing else to invalidate). - bpr := m.hexBytesPerRow() - m.hexTop = (m.hexTop / bpr) * bpr - m.rawTop = (m.rawTop / bpr) * bpr + m.byteViews.SnapTops(hexraw.BytesPerRow(m.viewContextPtr())) m.viewDirty = true } } -// cycleHexBytesPerRow steps the bytes-per-row preference through 8 → 16 → 32. -func cycleHexBytesPerRow(cur, dir int) int { - steps := []int{8, 16, 32} - i := 1 // default 16 - for j, v := range steps { - if v == cur { - i = j - break - } - } - return steps[(i+dir+len(steps))%len(steps)] -} - // clearDisasmDisplayCaches drops the caches whose geometry/content depends on the // disasm byte-column and annotation settings, so a toggle shows immediately. The // hex view is rendered uncached, so it needs no clear. func (m *Model) clearDisasmDisplayCaches() { - m.disasmHeightCache = nil - m.sourceAsmRowCache = nil -} - -// cycleIndex returns the index of cur in list stepped by dir (wrapping); a value -// not in the list is treated as index 0. -func cycleIndex(list []string, cur string, dir int) int { - i := 0 - for j, v := range list { - if strings.EqualFold(v, cur) { - i = j - break - } - } - return (i + dir + len(list)) % len(list) + m.dasm.HeightCache = nil + m.dasm.AnnCache = nil + m.dasm.SourceAsmRowCache = nil } // applyThemeChange rebuilds the theme (and source highlighter) from the live @@ -236,18 +117,20 @@ func (m *Model) applyThemeChange() { m.viewDirty = true } -func (m *Model) persistSettings() { +// PersistSettings saves the live config, reporting the outcome. It satisfies +// settings.Host; closing the overlay is the overlay's own business. +func (m *Model) PersistSettings() { path, err := config.Save(effectiveThemeName(m.cfg.Theme), m.cfg.Behavior) if err != nil { m.setStatus(fmt.Sprintf("settings applied for this session (not saved: %v)", err), true) - } else { - m.setStatus("settings saved to "+path, false) + return } - m.closeSettings() + m.setStatus("settings saved to "+path, false) } -// settingsValue returns field i's current value as a display string. -func (m *Model) settingsValue(i int) string { +// SettingValue returns field i's current value as a display string. It satisfies +// settings.Host; the index is the position in settings.Metas. +func (m *Model) SettingValue(i int) string { onOff := func(b bool) string { if b { return "on" @@ -301,75 +184,7 @@ func (m *Model) settingsValue(i int) string { } return "full" case 15: - return strconv.Itoa(m.hexBytesPerRow()) + return strconv.Itoa(hexraw.BytesPerRow(m.viewContextPtr())) } return "" } - -func (m *Model) renderSettingsModal() string { - const labelW, valW = 17, 16 // label column, then "‹ value ›" - leftW := labelW + valW + 6 // " label ‹ value ›" full control width - showDesc := m.width >= 84 // drop the description column on narrow terminals - - // Window the field list to the terminal height (title/hint/border cost ~8 - // rows) so the popup never overruns a short window; the selection stays - // visible as it scrolls. The budget is counted in rendered lines, which - // include the per-group headers and separators (settingsRowHeight). - total := 0 - for i := range settingsFieldCount { - total += settingsRowHeight(i) - } - visible := clamp(m.height-8, 5, total) - top := visualTop(m.settingsCur, m.settingsTop, settingsFieldCount, visible, settingsRowHeight) - m.settingsTop = top - m.modalListRow = 2 // title(0) + blank(1) → list starts at content row 2 - - desc := func(s string) string { return m.theme.srcShadowStyle.Render(s) } - group := func(s string) string { return m.theme.symbolNameStyle.Render(strings.ToUpper(s)) } - - var b strings.Builder - b.WriteString(m.theme.modalTitle("Settings")) - b.WriteString("\n\n") - - m.settingsLineFields = m.settingsLineFields[:0] - emit := func(line string, field int) { - b.WriteString(line) - b.WriteByte('\n') - m.settingsLineFields = append(m.settingsLineFields, field) - } - used := 0 - for i := top; i < settingsFieldCount; i++ { - h := settingsRowHeight(i) - if i == top && settingsGroupLead(i) && i != 0 { - h-- // the leading blank separator is suppressed at the window top - } - if used+h > visible { - break - } - used += h - if settingsGroupLead(i) { - if i != top && i != 0 { - emit("", -1) // blank separator above the group - } - emit(" "+group(settingsMetas[i].group), -1) - } - left := fmt.Sprintf(" %-*s ‹ %-*s ›", labelW, settingsMetas[i].label, valW, m.settingsValue(i)) - left = padRight(left, leftW) - if i == m.settingsCur { - left = m.theme.tableSelStyle.Render(left) - } - row := left - if showDesc { - row += " " + desc(settingsMetas[i].desc) - } - emit(row, i) - } - - b.WriteString("\n") - hint := "↑/↓ field · ←/→ change · Enter save · Esc cancel" - if visible < total { - hint += fmt.Sprintf(" (%d/%d)", m.settingsCur+1, settingsFieldCount) - } - b.WriteString(m.theme.modalHint(hint)) - return m.theme.modalStyle.Render(b.String()) -} diff --git a/internal/ui/settings_test.go b/internal/ui/settings_test.go index b33a27b..2e16b10 100644 --- a/internal/ui/settings_test.go +++ b/internal/ui/settings_test.go @@ -14,40 +14,40 @@ func TestSettingsCycleAndPersist(t *testing.T) { m := &Model{theme: DefaultTheme(), file: &binfile.File{}} m.openSettings() - if !m.settingsActive { + if !m.settings.Active() { t.Fatal("openSettings did not activate the popup") } // Field 0: theme — right cycles off the default. - m.updateSettings("right") + m.settings.Update(m, "right") if m.cfg.Theme == "" || m.cfg.Theme == defaultThemeName { t.Fatalf("theme did not cycle, got %q", m.cfg.Theme) } // Field 1: background — space toggles it on. - m.updateSettings("down") - m.updateSettings(" ") + m.settings.Update(m, "down") + m.settings.Update(m, " ") if !m.cfg.Behavior.Background { t.Fatal("background toggle did not turn on") } // Field 2: default wrap — space toggles it on and applies it for the session. - m.updateSettings("down") - m.updateSettings(" ") + m.settings.Update(m, "down") + m.settings.Update(m, " ") if !m.cfg.Behavior.DefaultWrap || !m.wrap { t.Fatal("default wrap toggle did not turn on") } // Field 3: default view — right cycles to a non-empty view name. - m.updateSettings("down") - m.updateSettings("right") + m.settings.Update(m, "down") + m.settings.Update(m, "right") if m.cfg.Behavior.DefaultView == "" { t.Fatal("default view did not cycle") } // Enter persists and closes. - m.updateSettings("enter") - if m.settingsActive { + m.settings.Update(m, "enter") + if m.settings.Active() { t.Fatal("Enter should close the popup") } c, err := config.Load() @@ -70,36 +70,36 @@ func TestSettingsNewFields(t *testing.T) { // Disasm target (field 4): cycling lands on a known strategy and updates the // live target used for default landings. - m.settingsCur = 4 - m.cycleSetting(1) + m.settings.SetCur(4) + m.CycleSetting(m.settings.Cur(), 1) if m.cfg.Behavior.DefaultDisasmTarget == "" || m.disasmTarget != m.cfg.Behavior.DefaultDisasmTarget { t.Fatalf("disasm target not applied live: cfg=%q live=%q", m.cfg.Behavior.DefaultDisasmTarget, m.disasmTarget) } // Demangle (field 10): toggling flips the (negated) preference. - m.settingsCur = 10 - m.cycleSetting(1) + m.settings.SetCur(10) + m.CycleSetting(m.settings.Cur(), 1) if !m.cfg.Behavior.NoDemangle { t.Fatal("demangle toggle did not set NoDemangle") } // Compact addresses (field 14): toggling sets the flag and pushes it to the file. - m.settingsCur = 14 - m.cycleSetting(1) + m.settings.SetCur(14) + m.CycleSetting(m.settings.Cur(), 1) if !m.cfg.Behavior.CompactAddresses { t.Fatal("compact-addresses toggle did not set the flag") } // Hex bytes/row (field 15): cycles 16 → 32. - m.settingsCur = 15 - m.cycleSetting(1) + m.settings.SetCur(15) + m.CycleSetting(m.settings.Cur(), 1) if m.cfg.Behavior.HexBytesPerRow != 32 { t.Fatalf("hex bytes/row = %d, want 32", m.cfg.Behavior.HexBytesPerRow) } // Persist and reload: every new field round-trips. - m.persistSettings() + m.PersistSettings() c, err := config.Load() if err != nil { t.Fatalf("reload: %v", err) diff --git a/internal/ui/sort_header.go b/internal/ui/sort_header.go index 79291b2..e7add3b 100644 --- a/internal/ui/sort_header.go +++ b/internal/ui/sort_header.go @@ -1,249 +1,14 @@ package ui -import ( - "strings" - - "charm.land/lipgloss/v2" -) - -type sortableHeaderCol[T comparable] struct { - start, end int - sort T -} - -func sortHeaderLabel[T comparable](label string, width int, sort, active T, desc bool) string { - if sort != active { - return label - } - return activeSortHeaderLabel(label, width, desc) -} - -func activeSortHeaderLabel(label string, width int, desc bool) string { - triangle := sortTriangle(desc) - labelW := lipgloss.Width(label) - triangleW := lipgloss.Width(triangle) - if width <= labelW+triangleW { - return label + triangle - } - return label + strings.Repeat(" ", width-labelW-triangleW) + triangle -} - -func trailingSortHeaderLabel[T comparable](label string, sort, active T, desc bool) string { - if sort != active { - return label - } - return activeSortHeaderLabel(label, lipgloss.Width(label)+3, desc) -} - -func sortTriangle(desc bool) string { - if desc { - return "▽" - } - return "△" -} - -func hitSortableHeader[T comparable](cols []sortableHeaderCol[T], x int) (T, bool) { - for _, col := range cols { - if x >= col.start && x < col.end { - return col.sort, true - } - } - var zero T - return zero, false -} - -// applySortHeaderClick mirrors htop-style header sorting: choosing a different -// column sorts it ascending; clicking the active column reverses the direction. -func applySortHeaderClick[T comparable](active *T, desc *bool, sort T) bool { - if *active == sort { - *desc = !*desc - return false - } - *active = sort - *desc = false - return true -} - -func sortDirectionLabel(desc bool) string { - if desc { - return "descending" - } - return "ascending" -} +// The generic sort-header primitives (SortableHeaderCol, SortHeaderLabel, +// HitSortableHeader, ApplySortHeaderClick, SortDirectionLabel, …) live in +// internal/ui/layout, and each view's click handler lives with its state in +// internal/ui/views/*. This file keeps only the mode dispatch. func (m *Model) handleSortableHeaderClick(x, bodyRow int) bool { - switch m.mode { - case modeSections: - return bodyRow == 1 && m.clickSectionsHeader(x) - case modeSymbols: - return bodyRow == 1 && m.clickSymbolsHeader(x) - case modeStrings: - return bodyRow == 1 && m.clickStringsHeader(x) - case modeRelocs: - return bodyRow == 1 && m.clickRelocsHeader(x) - case modeLibs: - return bodyRow == m.libsTitleRow() && m.clickLibsHeader(x) - } - return false + return m.current().sortHeaderClick(x, bodyRow) } func (m *Model) isTableHeaderRow(bodyRow int) bool { - switch m.mode { - case modeSections, modeSymbols: - return bodyRow == 1 - case modeStrings: - m.ensureStrings() - return len(m.stringsList) > 0 && bodyRow == 1 - case modeRelocs: - return bodyRow == 1 - case modeLibs: - return bodyRow == m.libsTitleRow() - } - return false -} - -func (m *Model) clickSectionsHeader(x int) bool { - addrW := m.file.AddrHexWidth() - phys := m.sectionsHavePhys() - if m.showSegments { - phys = m.segmentsHavePhys() - } - sort, ok := hitSortableHeader(m.sectionHeaderCols(addrW, phys), x) - if !ok { - return false - } - fieldChanged := applySortHeaderClick(&m.sectionsSort, &m.sectionsSortDesc, sort) - m.sectionsCur, m.sectionsTop = 0, 0 - m.recomputeSections() - if fieldChanged { - m.setStatus("sort: "+m.sectionsSort.String(), false) - } else { - m.setStatus("sort order: "+sortDirectionLabel(m.sectionsSortDesc), false) - } - return true -} - -func (m *Model) sectionHeaderCols(addrW int, phys bool) []sortableHeaderCol[sectionSort] { - addrCol := 2 + addrW - nameStart, nameW, typeW := 6, 22, 14 - if m.showSegments { - nameW, typeW = 16, 5 - } - addrStart := nameStart + nameW + 1 + typeW + 1 - sizeStart := addrStart + addrCol + 1 - if phys { - sizeStart += addrCol + 1 - } - return []sortableHeaderCol[sectionSort]{ - {start: 1, end: 4, sort: secSortIndex}, - {start: nameStart, end: nameStart + nameW, sort: secSortName}, - {start: addrStart, end: addrStart + addrCol, sort: secSortAddr}, - {start: sizeStart, end: sizeStart + 12, sort: secSortSize}, - } -} - -func (m *Model) clickSymbolsHeader(x int) bool { - if m.symbolTreeActive() { - return false - } - sort, ok := hitSortableHeader(m.symbolHeaderCols(m.file.AddrHexWidth()), x) - if !ok { - return false - } - fieldChanged := applySortHeaderClick(&m.symbolsSort, &m.symbolsSortDesc, sort) - m.symbolsCur, m.symbolsTop = 0, 0 - m.recomputeSymbols() - if fieldChanged { - m.setStatus("sort: "+m.symbolsSort.String(), false) - } else { - m.setStatus("sort order: "+sortDirectionLabel(m.symbolsSortDesc), false) - } - return true -} - -func (m *Model) symbolHeaderCols(addrW int) []sortableHeaderCol[symbolSort] { - addrCol := 2 + addrW - sizeStart := addrCol + 2 - nameStart := addrCol + 28 - return []sortableHeaderCol[symbolSort]{ - {start: 1, end: 1 + addrCol, sort: sortByAddr}, - {start: sizeStart, end: sizeStart + 9, sort: sortBySize}, - {start: nameStart, end: m.width, sort: sortByName}, - } -} - -func (m *Model) clickStringsHeader(x int) bool { - m.ensureStrings() - if len(m.stringsList) == 0 { - return false - } - sort, ok := hitSortableHeader(m.stringHeaderCols(m.file.AddrHexWidth()), x) - if !ok { - return false - } - fieldChanged := applySortHeaderClick(&m.stringsSort, &m.stringsSortDesc, sort) - m.stringsCur, m.stringsTop = 0, 0 - m.recomputeStrings() - if fieldChanged { - m.setStatus("sort: "+m.stringsSort.String(), false) - } else { - m.setStatus("sort order: "+sortDirectionLabel(m.stringsSortDesc), false) - } - return true -} - -func (m *Model) stringHeaderCols(addrW int) []sortableHeaderCol[stringSort] { - addrCol := 2 + addrW - addrStart := 12 - stringStart := addrW + 33 - return []sortableHeaderCol[stringSort]{ - {start: 1, end: 11, sort: strSortOffset}, - {start: addrStart, end: addrStart + addrCol, sort: strSortAddr}, - {start: stringStart, end: m.width, sort: strSortText}, - } -} - -func (m *Model) clickRelocsHeader(x int) bool { - sort, ok := hitSortableHeader(m.relocHeaderCols(m.file.AddrHexWidth()), x) - if !ok { - return false - } - fieldChanged := applySortHeaderClick(&m.relocSort, &m.relocSortDesc, sort) - m.relocCur, m.relocTop = 0, 0 - m.recomputeRelocs() - if fieldChanged { - m.setStatus("sort: "+m.relocSort.String(), false) - } else { - m.setStatus("sort order: "+sortDirectionLabel(m.relocSortDesc), false) - } - return true -} - -// relocHeaderCols maps the relocation table's header columns to their x ranges, -// matching the layout in renderRelocs / relocRow. -func (m *Model) relocHeaderCols(addrW int) []sortableHeaderCol[relocSortField] { - offCol := 2 + addrW - typeStart := 1 + offCol + 2 - secStart := typeStart + 24 + 1 - symStart := secStart + 12 + 1 - return []sortableHeaderCol[relocSortField]{ - {start: 1, end: 1 + offCol, sort: relocSortOffset}, - {start: typeStart, end: typeStart + 24, sort: relocSortType}, - {start: secStart, end: secStart + 12, sort: relocSortSection}, - {start: symStart, end: m.width, sort: relocSortSym}, - } -} - -func (m *Model) clickLibsHeader(x int) bool { - if m.file.Info == nil || len(m.file.Info.DynamicLibs) == 0 { - return false - } - if x < 1 || x >= 1+m.libsTitleWidth() { - return false - } - m.libsSortDesc = !m.libsSortDesc - m.libsCur, m.libsTop = 0, 0 - m.buildLibRows() - m.setStatus("sort order: "+sortDirectionLabel(m.libsSortDesc), false) - return true + return m.current().headerRow(bodyRow) } diff --git a/internal/ui/srchighlight.go b/internal/ui/srchighlight.go index 45d7ed0..ea524de 100644 --- a/internal/ui/srchighlight.go +++ b/internal/ui/srchighlight.go @@ -1,67 +1,18 @@ package ui -// Shared highlight policy for the split source/disasm panes. -// -// Both layouts — source-first (source left, disasm right) and disasm-first -// (disasm left, source right) — must colour code lines and instruction -// addresses identically. Rather than duplicate that decision in each renderer, -// it lives here as one small policy layer that every pane renderer consumes: -// -// - srcGutter decides the colour of a source line's number gutter. -// - addrMapStyle decides the colour of an instruction's address column. -// -// The renderers above this layer only lay out text; they never decide colours. - -import ( - "fmt" - - "charm.land/lipgloss/v2" -) - -// srcGutter renders the line-number gutter for a source line. Only the number -// is coloured — never the whole line: the current (caret) line is highlighted, -// lines that have machine code mapped to them are white, and unmapped lines are -// dimmed. digits is the field width of the number; the returned string is -// digits+3 columns wide (" "). -func (m *Model) srcGutter(ln, curLine int, mapped map[int]bool, digits int) string { - switch { - case ln == curLine: - return m.theme.srcCurLineStyle.Render(fmt.Sprintf("%*d ▸ ", digits, ln)) - case mapped[ln]: - return m.theme.whiteStyle.Render(fmt.Sprintf("%*d · ", digits, ln)) - default: - return m.theme.srcShadowStyle.Render(fmt.Sprintf("%*d ", digits, ln)) - } -} - -// addrMapStyle classifies an instruction address against the current source -// location and returns the style its address column should use: dimmed when the -// address maps to no source line, the per-column colour when it maps to the -// current line (so it correlates with the source carets), and white when it -// maps to some other line. -func (m *Model) addrMapStyle(addr uint64, curFile string, curLine int) lipgloss.Style { - f, l, c := m.file.LookupAddrCol(addr) - switch { - case f == "" || l == 0: - return m.theme.srcShadowStyle - case curFile != "" && f == curFile && l == curLine: - if st, ok := m.theme.columnStyle(m.sourceLineColumns(curFile, curLine), c); ok { - return st.Bold(true) - } - return m.theme.srcMappedStyle - default: - return m.theme.whiteStyle - } -} +// Shell glue for the split source/disasm panes. The shared colour policy the +// two layouts use identically (the srcGutter line-number colours and the +// AddrMapStyle instruction-address colours) lives with the renderers in +// views/disasm/sourcepane.go; what stays here needs the shell's mode. // rightPaneActive reports whether the disasm view is currently showing a second // (follower) pane that the independent-scroll controls apply to. func (m *Model) rightPaneActive() bool { - return m.mode == modeDisasm && m.showSource && m.file.HasDWARF() + return m.mode == modeDisasm && m.dasm.ShowSource && m.file.HasDWARF() } // scrollRightPane nudges the follower pane's independent scroll offset; the // renderers clamp it to the pane bounds. func (m *Model) scrollRightPane(delta int) { - m.rightScroll += delta + m.dasm.ScrollRightPane(delta) } diff --git a/internal/ui/status_chips_test.go b/internal/ui/status_chips_test.go new file mode 100644 index 0000000..eb87a81 --- /dev/null +++ b/internal/ui/status_chips_test.go @@ -0,0 +1,95 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/config" + "github.com/rabarbra/exex/internal/ui/view" +) + +// chipsFor renders a view and returns the chips its status line published. +func chipsFor(t *testing.T, m *Model, md mode) []view.StatusChip { + t.Helper() + enterMode(t, m, md) + _ = frame(m) + switch md { + case modeSections: + return m.sections.Chips + case modeSymbols: + return m.symbols.Chips + case modeStrings: + return m.strs.Chips + case modeSources: + return m.sources.Chips + case modeLibs: + return m.libs.Chips + case modeRelocs: + return m.relocs.Chips + } + return nil +} + +// TestEveryTableViewHasClickableChips pins the uniformity the status line is +// for: each table view publishes chips, and a click on one is that chip's key +// arriving by mouse. Only Symbols used to be clickable — the rest rendered +// look-alike text that did nothing. +func TestEveryTableViewHasClickableChips(t *testing.T) { + path := firstExisting("/bin/ls", "/usr/bin/true", "/bin/cat") + if path == "" { + t.Skip("no system binary available") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + + for _, md := range []mode{modeSections, modeSymbols, modeStrings, modeLibs, modeRelocs} { + t.Run(md.String(), func(t *testing.T) { + m, err := New(f, Options{Config: &config.Config{Theme: defaultThemeName}}) + if err != nil { + t.Fatalf("New: %v", err) + } + m.resize(160, 40) + m.applyThemeChange() + + chips := chipsFor(t, m, md) + if len(chips) == 0 { + t.Fatalf("%v published no status chips", md) + } + + // Every chip must lie inside the row and carry a key. + for _, c := range chips { + if c.Key == "" { + t.Errorf("chip at [%d,%d) has no key", c.Start, c.End) + } + if c.Start < 0 || c.End > m.width || c.Start >= c.End { + t.Errorf("chip %q spans [%d,%d), outside the %d-column row", c.Key, c.Start, c.End, m.width) + } + } + + // Clicking the first chip must change the status line — i.e. the click + // reached the view's own key handler. + before := ansi.Strip(strings.Split(frame(m), "\n")[1+statusRowOf(t, m, md)]) + c := chips[0] + m.handleClick((c.Start+c.End)/2, statusRowOf(t, m, md)+1) // +1: body starts at y=1 + after := ansi.Strip(strings.Split(frame(m), "\n")[1+statusRowOf(t, m, md)]) + if before == after { + t.Errorf("clicking the %q chip changed nothing\n row: %s", c.Key, before) + } + }) + } +} + +func statusRowOf(t *testing.T, m *Model, md mode) int { + t.Helper() + r := m.viewFor(md).statusRow() + if r < 0 { + t.Fatalf("%v has no status row", md) + } + return r +} diff --git a/internal/ui/stringsearch.go b/internal/ui/stringsearch.go new file mode 100644 index 0000000..ebe41b0 --- /dev/null +++ b/internal/ui/stringsearch.go @@ -0,0 +1,40 @@ +package ui + +// openStringSearch implements the -s CLI flag: it filters the printable strings +// by s and either jumps to the single match (Hex if mapped, else Raw) or opens +// the Strings view with the filter applied when several match. It needs the +// shell's mode switching, so it lives here; the list itself is +// internal/ui/views/strs. + +import ( + "fmt" + "strings" +) + +func (m *Model) openStringSearch(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + ctx := m.viewContext() + m.strs.Ensure(ctx) + m.strs.Filter.SetValue(s) + m.strs.Recompute(ctx) + m.strs.Cur, m.strs.Top = 0, 0 + switch len(m.strs.Filtered) { + case 0: + m.setMode(modeStrings) + m.setStatus(fmt.Sprintf("no strings match %q", s), true) + case 1: + e := m.strs.List[m.strs.Filtered[0]] + if e.HasAddr { + m.openHexAt(e.Addr) + } else { + m.openRawAt(e.Offset) + } + m.setStatus(fmt.Sprintf("string %q", s), false) + default: + m.setMode(modeStrings) + m.setStatus(fmt.Sprintf("%d strings match %q", len(m.strs.Filtered), s), false) + } +} diff --git a/internal/ui/view_strings_test.go b/internal/ui/stringsearch_test.go similarity index 63% rename from internal/ui/view_strings_test.go rename to internal/ui/stringsearch_test.go index 7a15541..bc0b70b 100644 --- a/internal/ui/view_strings_test.go +++ b/internal/ui/stringsearch_test.go @@ -16,28 +16,28 @@ func stringsModel(texts ...string) *Model { raw = append(raw, txt...) } m := &Model{ - theme: DefaultTheme(), - file: binfile.NewRawFile(raw), - mode: modeStrings, - layoutState: layoutState{width: 100, height: 24}, - stringsState: stringsState{stringsList: entries}, - } - m.stringsFilter = newPromptInput("", "/ ") - m.recomputeStrings() + theme: DefaultTheme(), + file: binfile.NewRawFile(raw), + mode: modeStrings, + layoutState: layoutState{width: 100, height: 24}, + } + m.strs.List = entries + m.strs.Filter = newPromptInput("", "/ ") + m.strs.Recompute(m.viewContext()) return m } func TestStringsFilter(t *testing.T) { m := stringsModel("hello world", "goodbye", "hello again") - if len(m.stringsFiltered) != 3 { - t.Fatalf("unfiltered count = %d, want 3", len(m.stringsFiltered)) + if len(m.strs.Filtered) != 3 { + t.Fatalf("unfiltered count = %d, want 3", len(m.strs.Filtered)) } - m.stringsFilter.SetValue("hello") - m.recomputeStrings() - if len(m.stringsFiltered) != 2 { - t.Fatalf("filtered count = %d, want 2", len(m.stringsFiltered)) + m.strs.Filter.SetValue("hello") + m.strs.Recompute(m.viewContext()) + if len(m.strs.Filtered) != 2 { + t.Fatalf("filtered count = %d, want 2", len(m.strs.Filtered)) } - if s, ok := m.currentString(); !ok || m.file.StringText(s) != "hello world" { + if s, ok := m.strs.Current(); !ok || m.file.StringText(s) != "hello world" { t.Fatalf("current = %q (%v), want 'hello world'", m.file.StringText(s), ok) } } @@ -50,8 +50,8 @@ func TestOpenStringSearch(t *testing.T) { if m.mode != modeStrings { t.Fatalf("multiple matches: mode = %v, want strings", m.mode) } - if len(m.stringsFiltered) != 2 { - t.Fatalf("multiple matches: filtered = %d, want 2", len(m.stringsFiltered)) + if len(m.strs.Filtered) != 2 { + t.Fatalf("multiple matches: filtered = %d, want 2", len(m.strs.Filtered)) } // No match → Strings view with an error status, not a crash. diff --git a/internal/ui/style.go b/internal/ui/style.go index de6865b..3c228ba 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -1,6 +1,7 @@ package ui import ( + "github.com/rabarbra/exex/internal/ui/layout" "strings" "charm.land/lipgloss/v2" @@ -45,6 +46,12 @@ type Theme struct { treeNodeStyle lipgloss.Style // collapsible group rows in the symbols/sources/libs trees viewStyle lipgloss.Style + // The painters for the styles that fill a whole line on every frame: the + // panel background behind the frame, the table-header line, the sticky title. + // Derived with the theme, not per frame — see derivePainters. + viewPainter layout.Painter + headerPainter layout.Painter + stickyPainter layout.Painter errorStyle lipgloss.Style infoStyle lipgloss.Style @@ -193,6 +200,18 @@ func (t *Theme) ApplyColors(c config.Colors) { t.pathPalette = stylePalette(c.PathPalette...) } t.deriveDisasmSel() + t.derivePainters() +} + +// derivePainters rebuilds the line-filling painters from the current theme. The +// background is applied to all three bands of every frame (tabs, body, footer), +// and the header/sticky lines are drawn every frame too — while deriving a +// style's SGR sequence costs a full lipgloss render. So they are derived once, +// with the theme, like disasmSelSeq below. +func (t *Theme) derivePainters() { + t.viewPainter = layout.NewPainter(t.viewStyle) + t.headerPainter = layout.NewPainter(t.tableHeaderStyle) + t.stickyPainter = layout.NewPainter(t.stickySymStyle) } // deriveDisasmSel recomputes disasmSelSeq from the current tableSelStyle so the diff --git a/internal/ui/symbolopen.go b/internal/ui/symbolopen.go new file mode 100644 index 0000000..b859395 --- /dev/null +++ b/internal/ui/symbolopen.go @@ -0,0 +1,85 @@ +package ui + +// Shell-side symbol routing shared across views: openSymbol picks the most +// useful view for a symbol, canDisasmAt gates the disasm fallback, and +// displaySymbolName applies the Symbols view's global argument abbreviation to +// the symbol names shown in disasm/hex/source annotations. The Symbols list +// itself lives in internal/ui/views/symbols. + +import ( + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/ui/views/symbols" +) + +// canDisasmAt reports whether addr can actually be disassembled: there is a +// decoder for this architecture and the address lives in executable code. When +// false (an unsupported CPU, or an address outside any mapped exec section), +// callers should fall back to the hex view rather than the disasm view. +func (m *Model) canDisasmAt(addr uint64) bool { + if m.dis == nil { + return false + } + _, ok := m.file.ExecImage().PosForAddr(addr) + return ok +} + +// openSymbol opens a symbol in the most appropriate view. The hex and disasm +// views span the whole binary now, so this only chooses which view to land in +// and seeks the cursor onto the symbol's address: +// - FUNC → disasm +// - OBJECT/TLS/COMMON → hex (virtual-address) view, cursor on the symbol +// - SECTION → exec ⇒ disasm; else hex/raw at the section +// - NOTYPE → exec section ⇒ disasm; else hex; else raw +// +// Anything that would land in disasm falls back to hex when disassembly isn't +// possible (no decoder for this CPU, or the address isn't in executable code). +func (m *Model) openSymbol(sym binfile.Symbol) { + wantDisasm := false + switch sym.Kind { + case binfile.SymFunc: + wantDisasm = true + case binfile.SymObject, binfile.SymTLS, binfile.SymCommon: + wantDisasm = false + default: + if sec := m.file.SectionAt(sym.Addr); sec != nil && binfile.IsExecSection(sec) { + wantDisasm = true + } + } + if wantDisasm && m.canDisasmAt(sym.Addr) { + m.loadDisasmAt(sym.Addr) + } else { + m.openHexAt(sym.Addr) + } +} + +// displaySymbolName returns a symbol's display name with bracketed argument and +// template lists abbreviated (see symbols.AbbrevBrackets) when the global +// Symbols-view "args" collapse is on, so a symbol reads the same in the disasm, +// hex/raw and pointer-follow annotations as it does in the Symbols list. The +// Symbols view's per-row overrides are list-specific and intentionally don't +// apply here. +func (m *Model) displaySymbolName(s binfile.Symbol) string { + if m.symbols.Abbrev { + return symbols.AbbrevBrackets(s.Display()) + } + return s.Display() +} + +// displaySymName is displaySymbolName for a bare linker name (a reloc bind target +// has no Symbol record): demangle it in-process unless the "Demangle symbols" +// setting is off, then apply the same argument-abbreviation collapse, so a reloc +// target reads like the same symbol does in the Symbols/disasm views. An +// unrecognised mangling (or an already-plain C name) is returned unchanged. +func (m *Model) displaySymName(name string) string { + if name == "" || m.cfg.Behavior.NoDemangle { + return name + } + d := binfile.DemangleName(name) + if d == "" { + return name + } + if m.symbols.Abbrev { + return symbols.AbbrevBrackets(d) + } + return d +} diff --git a/internal/ui/symbolopen_test.go b/internal/ui/symbolopen_test.go new file mode 100644 index 0000000..35d46ab --- /dev/null +++ b/internal/ui/symbolopen_test.go @@ -0,0 +1,32 @@ +package ui + +import ( + "testing" + + "github.com/rabarbra/exex/internal/binfile" +) + +func TestDisplaySymbolNameUsesGlobalAbbrevOnly(t *testing.T) { + m := &Model{file: &binfile.File{Symbols: []binfile.Symbol{ + {Name: "raw", Demangled: "foo(int, char)", Addr: 0x1000}, + }}} + m.symbols.Filter = newPromptInput("", "/ ") + s := m.file.Symbols[0] + + // Off by default: the full demangled name (used by disasm/hex annotations). + if got := m.displaySymbolName(s); got != "foo(int, char)" { + t.Fatalf("default = %q", got) + } + // Global toggle on: bracket lists collapse everywhere the helper is used. + m.symbols.Abbrev = true + if got := m.displaySymbolName(s); got != "foo<...>(...)" { + t.Fatalf("global on = %q", got) + } + // Symbols-list per-row overrides are list-specific and must not leak here. + m.symbols.Recompute(m.viewContext()) + m.symbols.Cur = 0 + m.symbols.ToggleAbbrev(m) // sets a per-row override on row 0 + if got := m.displaySymbolName(s); got != "foo<...>(...)" { + t.Fatalf("per-row override leaked into shared helper = %q", got) + } +} diff --git a/internal/ui/syntax_theme.go b/internal/ui/syntax_theme.go index 0dbf98d..1313f25 100644 --- a/internal/ui/syntax_theme.go +++ b/internal/ui/syntax_theme.go @@ -24,11 +24,5 @@ func sourceSyntaxTheme(cfg config.Config) string { } func sourceSyntaxForeground(cfg config.Config) string { - if p, ok := theme.PaletteFor(sourceSyntaxTheme(cfg)); ok { - return p.Foreground - } - if p, ok := theme.PaletteFor(defaultThemeName); ok { - return p.Foreground - } - return "" + return theme.ForegroundFor(sourceSyntaxTheme(cfg)) } diff --git a/internal/ui/syscalls.go b/internal/ui/syscalls.go index aec4ae8..53bef91 100644 --- a/internal/ui/syscalls.go +++ b/internal/ui/syscalls.go @@ -10,19 +10,15 @@ package ui import ( "fmt" - "runtime" "sort" - "strconv" "strings" "sync" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/dump" + syscallsmodal "github.com/rabarbra/exex/internal/ui/modals/syscalls" ) // syscallMaxHits caps how many syscall sites are collected (the modal scrolls). @@ -36,317 +32,21 @@ const syscallLead = 1 << 10 // the syscall-number register (matches dump's recovery window). const syscallScanBack = 32 -// syscallScope selects which sites the modal lists. -type syscallScope uint8 - -const ( - sysScopeFunc syscallScope = iota // only the function under the cursor - sysScopeAll // every site in the binary - sysScopeUnique // one row per distinct syscall number - sysScopeFull // unique across the binary + its linked libs - sysScopeCount -) - -// syscallSortKey selects how the modal orders its rows. Each key has a "natural" -// direction (number/name/address ascending, count descending) that `r` reverses. -type syscallSortKey uint8 - -const ( - sysSortNumber syscallSortKey = iota // grouped: numbered (by number), vDSO, unresolved - sysSortName // resolved name (A→Z) - sysSortCount // occurrences (most-used first) - sysSortAddr // first site's address (execution order) - sysSortKeyCount -) - -func (k syscallSortKey) String() string { - switch k { - case sysSortName: - return "name" - case sysSortCount: - return "count" - case sysSortAddr: - return "address" - default: - return "number" - } -} - -// syscallState holds the syscall scan + modal state. +// syscallState holds the syscall scans' async bookkeeping and the direct scan's +// result cache. The overlay's own state lives on m.syscalls +// (internal/ui/modals/syscalls). type syscallState struct { - syscallActive bool // results modal open - syscallRunning bool // background scan in flight + syscallRunning bool // direct scan in flight syscallSeq int // guards against stale async results syscallCancel chan struct{} - syscallResults []dump.SyscallSite syscallCached map[bool][]dump.SyscallSite // key: file.DisasmAll() - syscallScope syscallScope - syscallShown []syscallRow // rows for the active scope, rebuilt on scan/scope/sort/filter change - syscallSel int - syscallTop int - - // Sort + free-text filter applied to the active scope's rows. - syscallSort syscallSortKey - syscallSortDesc bool - syscallFilter textinput.Model - syscallFiltering bool // filter input focused (typing edits it) - syscallTotal int // rows in the active scope before the text filter - syscallFnLo uint64 // function-under-cursor range, to mark/pre-select its sites - syscallFnHi uint64 - syscallFnName string // Full scope (binary + linked libraries), scanned lazily off-thread. - syscallFull []dump.SyscallSite - syscallFullNotes []string // libraries that couldn't be scanned - syscallFullObjs int // objects scanned - syscallFullDone bool syscallFullRunning bool syscallFullSeq int syscallFullCancel chan struct{} } -// syscallRow is one displayed line: a representative site and, in unique scope, -// how many sites share its number. -type syscallRow struct { - site dump.SyscallSite - count int -} - -// inFunc reports whether addr is inside the function the scan was launched from. -func (m *Model) inFunc(addr uint64) bool { - return m.syscallFnHi > m.syscallFnLo && addr >= m.syscallFnLo && addr < m.syscallFnHi -} - -// rebuildSyscallRows recomputes the displayed rows for the active scope into the -// cached syscallShown slice. Called only when the scan results or scope change, -// so per-frame rendering and per-event mouse mapping reuse the slice instead of -// re-deriving (and re-allocating) it every time. -func (m *Model) rebuildSyscallRows() { - rows := m.syscallShown[:0] - switch m.syscallScope { - case sysScopeFunc: - for _, s := range m.syscallResults { - if m.inFunc(s.Addr) { - rows = append(rows, syscallRow{site: s, count: 1}) - } - } - case sysScopeUnique: - rows = uniqueSyscallRows(m.syscallResults, rows) - case sysScopeFull: - rows = uniqueSyscallRows(m.syscallFull, rows) - default: // sysScopeAll - for _, s := range m.syscallResults { - rows = append(rows, syscallRow{site: s, count: 1}) - } - } - sortSyscallRows(rows, m.syscallSort, m.syscallSortDesc) - m.syscallTotal = len(rows) - - // Apply the free-text filter (compacting in place — kept index never overtakes - // the read index, so the shared backing array is safe to reuse). - if needle := strings.ToLower(strings.TrimSpace(m.syscallFilter.Value())); needle != "" { - kept := rows[:0] - for _, r := range rows { - if syscallRowMatches(r, needle) { - kept = append(kept, r) - } - } - rows = kept - } - m.syscallShown = rows - if m.syscallSel >= len(rows) { - m.syscallSel = max(0, len(rows)-1) - } -} - -// sortSyscallRows orders rows by the chosen key. The default (number) groups them -// like the dump: numbered first (ascending), then vDSO, then unresolved. -func sortSyscallRows(rows []syscallRow, key syscallSortKey, desc bool) { - less := func(i, j int) bool { - a, b := rows[i], rows[j] - switch key { - case sysSortName: - if a.site.Name != b.site.Name { - return a.site.Name < b.site.Name - } - return syscallNumberLess(a.site, b.site) - case sysSortCount: - if a.count != b.count { - return a.count > b.count // most-used first - } - return syscallNumberLess(a.site, b.site) - case sysSortAddr: - return a.site.Addr < b.site.Addr - default: // sysSortNumber - return syscallNumberLess(a.site, b.site) - } - } - sort.SliceStable(rows, func(i, j int) bool { - if desc { - return less(j, i) - } - return less(i, j) - }) -} - -// syscallNumberLess is the dump's canonical order: numbered sites first (by -// number), then vDSO calls, then unresolved sites (by instruction text). -func syscallNumberLess(a, b dump.SyscallSite) bool { - if a.HasNum != b.HasNum { - return a.HasNum - } - if a.HasNum { - return a.Num < b.Num - } - if a.VDSO != b.VDSO { - return a.VDSO - } - return a.Text < b.Text -} - -// syscallRowMatches reports whether a row matches the (lower-cased) filter needle, -// testing the resolved name, the number (decimal and 0x hex), the symbol/origin -// and the instruction text — so "write", "4", "0x4" or "_start" all narrow. -func syscallRowMatches(r syscallRow, needle string) bool { - s := r.site - if containsFold(s.Name, needle) || containsFold(s.Sym, needle) || - containsFold(s.Origin, needle) || containsFold(s.Text, needle) { - return true - } - if s.HasNum { - if containsFold(strconv.FormatInt(s.Num, 10), needle) || - containsFold("0x"+strconv.FormatInt(s.Num, 16), needle) { - return true - } - } - return s.VDSO && containsFold("vdso", needle) -} - -// syscallCategory classifies a site for colouring: resolved-to-a-name, number-only -// (known number but no table entry), vDSO, or unresolved. -type syscallCategory uint8 - -const ( - catNamed syscallCategory = iota // number resolved to a table name - catNumberOnly // number known, not in the table - catVDSO // vDSO / __kernel_ helper call - catUnresolved // couldn't recover the number -) - -func syscallCategoryOf(s dump.SyscallSite) syscallCategory { - switch { - case s.HasNum && s.Name != "": - return catNamed - case s.HasNum: - return catNumberOnly - case s.VDSO: - return catVDSO - default: - return catUnresolved - } -} - -// syscallCatStyle maps a category to its theme colour. -func (m *Model) syscallCatStyle(c syscallCategory) lipgloss.Style { - switch c { - case catNamed: - return m.theme.infoStyle // green - case catNumberOnly: - return m.theme.warnStyle // yellow - case catVDSO: - return m.theme.headerKey // blue/cyan - default: - return m.theme.srcShadowStyle // dim - } -} - -// uniqueSyscallRows aggregates sites into one row per distinct syscall (number, -// or vDSO/unresolved text), counting occurrences. The first site of each kind is -// kept as the representative (carrying its origin, for the full scope). -func uniqueSyscallRows(sites []dump.SyscallSite, rows []syscallRow) []syscallRow { - idx := make(map[string]int, len(sites)) - var key [24]byte - for _, s := range sites { - var k string - switch { - case s.HasNum: - k = "n" + string(strconv.AppendInt(key[:0], s.Num, 10)) - case s.VDSO: - k = "v" + s.Text - default: - k = "u" + s.Text - } - if j, ok := idx[k]; ok { - rows[j].count++ - continue - } - idx[k] = len(rows) - rows = append(rows, syscallRow{site: s, count: 1}) - } - return rows -} - -// scopeLabel names the active scope for the modal subtitle. -func (m *Model) scopeLabel() string { - switch m.syscallScope { - case sysScopeFunc: - if m.syscallFnName != "" { - return "in " + m.syscallFnName - } - return "this function" - case sysScopeUnique: - return "unique" - case sysScopeFull: - if m.syscallFullRunning { - return "full (+libs) — scanning…" - } - if m.syscallFullDone { - return fmt.Sprintf("full · binary + %d libs", max(0, m.syscallFullObjs-1)) - } - return "full (+libs)" - default: - return "whole binary" - } -} - -// syscallScopeBar renders the four scopes as a segmented control with the active -// one highlighted, so the t-cycle's options and current position are explicit -// rather than hidden behind a keystroke. -func (m *Model) syscallScopeBar() string { - names := [sysScopeCount]string{"function", "binary", "unique", "full+libs"} - var b strings.Builder - b.WriteString(m.theme.modalHint("scope ")) - for i, n := range names { - if i > 0 { - b.WriteString(m.theme.srcShadowStyle.Render(" ")) - } - if syscallScope(i) == m.syscallScope { - b.WriteString(m.theme.tableSelStyle.Render(" " + n + " ")) - } else { - b.WriteString(m.theme.srcShadowStyle.Render(" " + n + " ")) - } - } - if m.syscallScope == sysScopeFunc && m.syscallFnName != "" { - b.WriteString(m.theme.modalHint(" " + m.syscallFnName)) - } - return b.String() -} - -// syscallLegend renders the colour key (named / num-only / vdso / unresolved) and -// the active sort, so the row colouring is self-explanatory. -func (m *Model) syscallLegend() string { - sep := m.theme.srcShadowStyle.Render(" · ") - dir := "↑" - if m.syscallSortDesc { - dir = "↓" - } - return m.syscallCatStyle(catNamed).Render("named") + sep + - m.syscallCatStyle(catNumberOnly).Render("num-only") + sep + - m.syscallCatStyle(catVDSO).Render("vdso") + sep + - m.syscallCatStyle(catUnresolved).Render("unresolved") + - m.theme.modalHint(" sort: "+m.syscallSort.String()+dir) -} - // syscallDoneMsg delivers a finished syscall scan. type syscallDoneMsg struct { file *binfile.File @@ -357,26 +57,26 @@ type syscallDoneMsg struct { // startSyscallScan launches a syscall-site scan over the executable image, // remembering the function under the cursor so its sites can be highlighted. func (m *Model) startSyscallScan() tea.Cmd { - if m.dis == nil || len(m.disasmInst) == 0 { + if m.dis == nil || len(m.dasm.Inst) == 0 { m.setStatus("no disassembly to scan", true) return nil } - m.syscallFnLo, m.syscallFnHi, m.syscallFnName = 0, 0, "" - addr := m.disasmInst[m.disasmCur].Addr + var lo, hi uint64 + var name string + addr := m.dasm.Inst[m.dasm.Cur].Addr if sym, ok := m.file.SymbolAt(addr); ok && sym.Size > 0 { - m.syscallFnLo, m.syscallFnHi = sym.Addr, sym.Addr+sym.Size - m.syscallFnName = sym.Display() + lo, hi, name = sym.Addr, sym.Addr+sym.Size, sym.Display() } + m.syscalls.SetFunc(lo, hi, name) m.stopSyscallScan() m.syscallSeq++ m.syscallRunning = false all := m.file.DisasmAll() if sites, ok := m.syscallCached[all]; ok { if len(sites) == 0 { - m.setStatus("no syscalls found (cached)", true) - return nil + return m.openSyscallFullFallback() } - m.openSyscallResults(sites) + m.syscalls.Open(sites) m.setSyscallStatus(sites) return nil } @@ -384,7 +84,7 @@ func (m *Model) startSyscallScan() tea.Cmd { done := make(chan struct{}) m.syscallCancel = done m.setStatus("scanning for syscalls … (Esc cancels)", false) - return m.syscallScanCmd(m.syscallSeq, done) + return m.backgroundCmd(m.syscallScanCmd(m.syscallSeq, done)) } // syscallScanCmd decodes the executable image in parallel chunks (reusing the @@ -396,10 +96,6 @@ func (m *Model) syscallScanCmd(seq int, done <-chan struct{}) tea.Cmd { arch := m.file.Arch() symAt := dump.VDSOSymAt(file) // nil unless the binary has vDSO symbols chunk := m.disasmSearchChunkBytes() - maxWorkers := runtime.GOMAXPROCS(0) - if m.disasmSearchWorkers > 0 { - maxWorkers = m.disasmSearchWorkers - } return func() tea.Msg { var starts []int for pos := 0; pos < img.Len(); { @@ -412,7 +108,7 @@ func (m *Model) syscallScanCmd(seq int, done <-chan struct{}) tea.Cmd { } results := make([][]dump.SyscallSite, len(starts)) - workers := max(min(maxWorkers, len(starts)), 1) + workers := svc.SearchWorkersFor(len(starts)) sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, start := range starts { @@ -506,36 +202,30 @@ func (m *Model) handleSyscallDone(msg syscallDoneMsg) (tea.Model, tea.Cmd) { } m.syscallCached[m.file.DisasmAll()] = msg.sites if len(msg.sites) == 0 { - m.setStatus("no syscalls found", true) - return m, nil + return m, m.openSyscallFullFallback() } - m.openSyscallResults(msg.sites) + m.syscalls.Open(msg.sites) m.setSyscallStatus(msg.sites) return m, nil } -func (m *Model) openSyscallResults(sites []dump.SyscallSite) { - m.syscallResults = sites - m.syscallSel = 0 - m.syscallTop = 0 - m.syscallFilter.SetValue("") // a fresh scan starts unfiltered - m.syscallFilter.Blur() - m.syscallFiltering = false - inFn := 0 - for _, s := range sites { - if m.inFunc(s.Addr) { - inFn++ - } +// openSyscallFullFallback opens the modal straight in full (binary + libs) scope +// when the image itself has no direct syscall sites. A macOS executable never +// traps to the kernel itself — its syscalls live in libsystem_kernel, reached +// through the dyld shared cache — so rather than a bare "none found" that hides +// where the syscalls actually are, we surface the transitive scan (a statically +// linked ELF with none of its own works the same way against its libraries). +func (m *Model) openSyscallFullFallback() tea.Cmd { + needsScan := m.syscalls.OpenFull() + if m.syscalls.FullDone() { + m.setStatus("no direct syscalls — showing libraries · "+m.syscalls.ScopeLabel(), false) + return nil } - // Land in the function scope when the cursor's function has syscalls (the - // common "what does this function call?" question); otherwise show them all. - if inFn > 0 { - m.syscallScope = sysScopeFunc - } else { - m.syscallScope = sysScopeAll + m.setStatus("no direct syscalls — scanning libraries … (Esc cancels)", false) + if needsScan { + return m.StartFullScan() } - m.rebuildSyscallRows() - m.syscallActive = true + return nil } func (m *Model) setSyscallStatus(sites []dump.SyscallSite) { @@ -543,14 +233,9 @@ func (m *Model) setSyscallStatus(sites []dump.SyscallSite) { if len(sites) >= syscallMaxHits { capped = "+" } - inFn := 0 - for _, s := range sites { - if m.inFunc(s.Addr) { - inFn++ - } - } - if inFn > 0 && m.syscallFnName != "" { - m.setStatus(fmt.Sprintf("%d%s syscalls · %d in %s (t: scope)", len(sites), capped, inFn, m.syscallFnName), false) + inFn := m.syscalls.CountInFunc(sites) + if inFn > 0 && m.syscalls.FuncName() != "" { + m.setStatus(fmt.Sprintf("%d%s syscalls · %d in %s (t: scope)", len(sites), capped, inFn, m.syscalls.FuncName()), false) } else { m.setStatus(fmt.Sprintf("%d%s syscalls (t: scope)", len(sites), capped), false) } @@ -561,7 +246,7 @@ func (m *Model) cancelSyscall() { m.syscallSeq++ m.syscallRunning = false m.stopSyscallScan() - m.cancelSyscallFullScan() + m.CancelFullScan() m.setStatus("syscall scan cancelled", false) } @@ -584,18 +269,21 @@ type syscallFullDoneMsg struct { // startSyscallFullScan scans the binary and its linked libraries off the UI // goroutine (opening and decoding each library is I/O- and CPU-heavy, so it must // not block rendering). The result feeds the modal's full scope. -func (m *Model) startSyscallFullScan() tea.Cmd { +// StartFullScan satisfies syscalls.Host: the overlay asks for the library scan +// the first time its full scope is selected. +func (m *Model) StartFullScan() tea.Cmd { m.stopSyscallFullScan() m.syscallFullSeq++ m.syscallFullRunning = true + m.syscalls.SetFullRunning(true) seq := m.syscallFullSeq file := m.file done := make(chan struct{}) m.syscallFullCancel = done - return func() tea.Msg { + return m.backgroundCmd(func() tea.Msg { sites, objs, notes := dump.CollectSyscallsFullCancel(file, done) return syscallFullDoneMsg{file: file, seq: seq, sites: sites, objs: objs, notes: notes} - } + }) } func (m *Model) stopSyscallFullScan() { @@ -605,10 +293,13 @@ func (m *Model) stopSyscallFullScan() { } } -func (m *Model) cancelSyscallFullScan() { +// CancelFullScan satisfies syscalls.Host: the overlay abandons the library scan +// when it leaves the full scope, or jumps away. +func (m *Model) CancelFullScan() { if m.syscallFullRunning || m.syscallFullCancel != nil { m.syscallFullSeq++ m.syscallFullRunning = false + m.syscalls.SetFullRunning(false) m.stopSyscallFullScan() } } @@ -621,250 +312,9 @@ func (m *Model) handleSyscallFullDone(msg syscallFullDoneMsg) (tea.Model, tea.Cm } m.syscallFullRunning = false m.syscallFullCancel = nil - m.syscallFullDone = true - m.syscallFull = msg.sites - m.syscallFullObjs = msg.objs - m.syscallFullNotes = msg.notes - if m.syscallActive && m.syscallScope == sysScopeFull { - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() - m.setStatus("syscalls: "+m.scopeLabel(), false) - } - return m, nil -} - -// updateSyscallModal drives the results list. When the filter box is focused, -// typing edits it and only the arrows/Enter/Esc/Tab are special; otherwise t -// cycles scope, s/r sort, / focuses the filter, Enter jumps and Esc closes. -func (m *Model) updateSyscallModal(msg tea.KeyMsg, key string) (tea.Model, tea.Cmd) { - m.ensureSyscallFilter() - rows := m.syscallShown - if m.syscallFiltering { - switch key { - case "esc": // clear the filter and leave the box (modal stays open) - m.clearSyscallFilter() - return m, nil - case "up": - if m.syscallSel > 0 { - m.syscallSel-- - } - return m, nil - case "down": - if m.syscallSel < len(rows)-1 { - m.syscallSel++ - } - return m, nil - case "enter": - return m.syscallJump() - default: - if key == "tab" { // commit the filter, return to command keys - m.syscallFilter.Blur() - m.syscallFiltering = false - return m, nil - } - var cmd tea.Cmd - m.syscallFilter, cmd = m.syscallFilter.Update(msg) - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() - return m, cmd - } - } - - switch key { - case "esc": - m.syscallActive = false - case "/": - m.syscallFiltering = true - return m, m.syscallFilter.Focus() - case "t": - oldScope := m.syscallScope - m.syscallScope = (m.syscallScope + 1) % sysScopeCount - if oldScope == sysScopeFull && m.syscallScope != sysScopeFull { - m.cancelSyscallFullScan() - } - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() - m.setStatus("syscalls: "+m.scopeLabel(), false) - // Entering full scope kicks off the (lazy) binary + libraries scan. - if m.syscallScope == sysScopeFull && !m.syscallFullDone && !m.syscallFullRunning { - return m, m.startSyscallFullScan() - } - case "s": - m.syscallSort = (m.syscallSort + 1) % sysSortKeyCount - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() - m.setStatus("sort: "+m.syscallSort.String(), false) - case "r": - m.syscallSortDesc = !m.syscallSortDesc - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() - case "up", "k": - if m.syscallSel > 0 { - m.syscallSel-- - } - case "down", "j": - if m.syscallSel < len(rows)-1 { - m.syscallSel++ - } - case "enter": - return m.syscallJump() + m.syscalls.SetFullResults(msg.sites, msg.notes, msg.objs) + if m.syscalls.Active() && m.syscalls.Scope() == syscallsmodal.ScopeFull { + m.setStatus("syscalls: "+m.syscalls.ScopeLabel(), false) } return m, nil } - -// syscallJump follows the selected site to the disassembly, refusing sites that -// live in a linked library (a different address space). -func (m *Model) syscallJump() (tea.Model, tea.Cmd) { - rows := m.syscallShown - if m.syscallSel < 0 || m.syscallSel >= len(rows) { - return m, nil - } - site := rows[m.syscallSel].site - if site.Origin != "" && site.Origin != "this binary" { - m.setStatus("site is in "+site.Origin+" — open it as primary to inspect", true) - return m, nil - } - m.syscallActive = false - m.cancelSyscallFullScan() - m.loadDisasmAt(site.Addr) - return m, nil -} - -// ensureSyscallFilter guarantees the filter input is a fully-constructed -// textinput (its cursor's blink context is non-nil) before it is focused or -// rendered, so the modal can't panic even if the model was built without the -// New() initialiser. The zero value has an empty Prompt; a real one is "/ ". -func (m *Model) ensureSyscallFilter() { - if m.syscallFilter.Prompt == "" { - m.syscallFilter = newPromptInput("name · #num · symbol", "/ ") - } -} - -// clearSyscallFilter empties the filter, defocuses it and rebuilds the rows. -func (m *Model) clearSyscallFilter() { - m.syscallFilter.SetValue("") - m.syscallFilter.Blur() - m.syscallFiltering = false - m.syscallSel, m.syscallTop = 0, 0 - m.rebuildSyscallRows() -} - -func (m *Model) renderSyscallModal() string { - m.ensureSyscallFilter() - var sb strings.Builder - addrW := m.file.AddrHexWidth() - rowW := modalListWidth(m.width) - visible := clamp(m.height-10, 3, 40) // 2 extra header lines (scope bar + legend) - rows := m.syscallShown - if m.syscallSel >= len(rows) { - m.syscallSel = max(0, len(rows)-1) - } - full := m.syscallScope == sysScopeFull - aggregated := m.syscallScope == sysScopeUnique || full - - // Column budget: "● ". - const sysNameW = 16 - avail := rowW - len("● ") - (2 + addrW) - len(" ") - sysNameW - len(" ") - len(" ") - 6 - textW := clamp(avail/3, 10, 32) - symW := max(8, avail-textW) - - // Header: title, scope segmented control, filter box (with shown/total count), - // and the colour/sort legend — then a blank line before the rows. - sb.WriteString(m.theme.modalTitle("System calls")) - sb.WriteString("\n") - sb.WriteString(fitANSIWidth(m.syscallScopeBar(), rowW)) - sb.WriteString("\n") - countStr := fmt.Sprintf(" %d", len(rows)) - if m.syscallTotal != len(rows) { - countStr = fmt.Sprintf(" %d of %d", len(rows), m.syscallTotal) - } - m.syscallFilter.SetWidth(clamp(rowW-len(countStr)-4, 12, 60)) - sb.WriteString(fitANSIWidth(m.syscallFilter.View()+m.theme.modalHint(countStr), rowW)) - sb.WriteString("\n") - sb.WriteString(fitANSIWidth(m.syscallLegend(), rowW)) - sb.WriteString("\n\n") - m.modalListRow = 5 // title + scope + filter + legend + blank - top := visualTop(m.syscallSel, m.syscallTop, len(rows), visible, func(int) int { return 1 }) - m.syscallTop = top - end := min(top+visible, len(rows)) - for i := top; i < end; i++ { - h := rows[i].site - loc := h.Sym - if full { // in the full scope the originating object is more useful than the symbol - loc = h.Origin - } - if loc == "" { - loc = "—" - } - mark := " " - if !aggregated && m.inFunc(h.Addr) { - mark = "●" - } - text := truncateMiddle(h.Text, textW) - if h.VDSO { - text += " ·vdso" - } - label := "" - switch { - case h.Name != "" && h.HasNum: - label = fmt.Sprintf("#%d %s", h.Num, h.Name) - case h.Name != "": - label = h.Name - case h.HasNum: - label = fmt.Sprintf("#%d", h.Num) - case h.VDSO: - label = "vdso" - } - // Colour the syscall label by resolution category (named / num-only / vdso / - // unresolved) so the eye can pick out which numbers actually mapped to a name. - num := m.syscallCatStyle(syscallCategoryOf(h)).Render(padVisual(truncateMiddle(label, sysNameW), sysNameW)) - // In aggregated scopes (unique / full) show a use count instead of an address. - first := fmt.Sprintf("0x%0*x", addrW, h.Addr) - if aggregated { - first = padVisual(fmt.Sprintf("%d×", rows[i].count), 2+addrW) - } - line := fmt.Sprintf("%s %s %s %s %s", - mark, first, num, - padVisual(truncateMiddle(loc, symW), symW), - text) - line = padVisual(line, rowW) - if i == m.syscallSel { // strip the category colour so the selection bar reads cleanly - line = m.theme.tableSelStyle.Render(ansi.Strip(line)) - } - sb.WriteString(line) - sb.WriteString("\n") - } - // Full scope: while the library scan runs, or if it found nothing, say so. - if full && len(rows) == 0 { - msg := "scanning binary + libraries…" - if m.syscallFullDone { - msg = "no syscalls found in the binary or its libraries" - } - sb.WriteString(" " + m.theme.srcShadowStyle.Render(msg) + "\n") - } - // Full scope: list libraries that couldn't be scanned, mirroring the dump. - if full && len(m.syscallFullNotes) > 0 { - sb.WriteString("\n") - sb.WriteString(" " + m.theme.warnStyle.Render(fmt.Sprintf("%d unresolved libraries:", len(m.syscallFullNotes))) + "\n") - shown := m.syscallFullNotes - if len(shown) > 4 { - shown = shown[:4] - } - for _, n := range shown { - sb.WriteString(" " + m.theme.srcShadowStyle.Render(fitANSIWidth(n, rowW)) + "\n") - } - if len(m.syscallFullNotes) > 4 { - sb.WriteString(" " + m.theme.srcShadowStyle.Render(fmt.Sprintf(" … and %d more", len(m.syscallFullNotes)-4)) + "\n") - } - } - - sb.WriteString("\n") - footer := fmt.Sprintf("↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (%d/%d)", - min(m.syscallSel+1, len(rows)), len(rows)) - if m.syscallFiltering { - footer = fmt.Sprintf("type to filter · ↵ jump · Tab done · Esc clear (%d/%d)", - min(m.syscallSel+1, len(rows)), len(rows)) - } - sb.WriteString(m.theme.modalHint(fitANSIWidth(footer, rowW))) - return m.theme.modalStyle.Render(sb.String()) -} diff --git a/internal/ui/testdata/golden-lite/modal_cpufeatures.frame b/internal/ui/testdata/golden-lite/modal_cpufeatures.frame new file mode 100644 index 0000000..89feca6 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_cpufeatures.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   CPU features   ││ +││ x86-64-v3 · 12345 instructions scanned ││ +││ ││ +││  AVX 42 × first at 0x0000000000401000  ││ +││ AVX512F  1 × first at 0x00000000004010ff ││ +││ BMI2  300 × first at 0x0000000000402000 ││ +││ SSE2  7 × first at 0x0000000000401020 ││ +││ ││ +││ ↑/↓ select · ↵ jump to first use · Esc close (1/4) ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_cpufeatures_empty.frame b/internal/ui/testdata/golden-lite/modal_cpufeatures_empty.frame new file mode 100644 index 0000000..fd0f78a --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_cpufeatures_empty.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│ OS/ABI  ELFOSABI_NONE │ +│ Type ╭───────────────────────────────────────────────────────────────╮ │ +│ Machine │ │ │ +│ Entry │   CPU features   │ │ +│ │ 99 instructions scanned │ │ +│  REQUIREMENTS │ │──────────────── │ +│ CPU / arch │ only base instructions — no optional CPU features detected │ │ +│ Linking │ │ │ +│ CPU feature│ ↑/↓ select · ↵ jump to first use · Esc close (0/0) │ │ +│ │ │ │ +│  OVERVIEW ────╰───────────────────────────────────────────────────────────────╯──────────────── │ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_find_query.frame b/internal/ui/testdata/golden-lite/modal_find_query.frame new file mode 100644 index 0000000..9949562 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_find_query.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   Search the binary   ││ +││ ││ +││ search s ││ +││ ││ +││ case-insensitive (^i) ││ +││ ││ +││ ↵ search disasm · data · strings · relocs · Esc cancel ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_find_results.frame b/internal/ui/testdata/golden-lite/modal_find_results.frame new file mode 100644 index 0000000..7d89aff --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_find_results.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Find _start   ✓ 3 found ││ +││ ││ +││  all 3   disasm 1   data 1   strings 1   relocs  ││ +││ / filter results  3 ││ +││ ││ +││  disasm 0x000000000040100e call 0x401020 _start  ││ +││ data  @0x000000000003000 pointer word .data ││ +││ strings  0x0000000000402000 hello world .rodata ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ view · / filter · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_find_results_searching.frame b/internal/ui/testdata/golden-lite/modal_find_results_searching.frame new file mode 100644 index 0000000..d346323 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_find_results_searching.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Find _start   ● searching 4 sources ││ +││ ││ +││  all 0   disasm   data   strings   relocs  ││ +││ / filter results  0 ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ searching … ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ view · / filter · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_find_seeds.frame b/internal/ui/testdata/golden-lite/modal_find_seeds.frame new file mode 100644 index 0000000..0bad18a --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_find_seeds.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2  3·Symbols  4 5 6 7 8 9 0 +/ symbols (3 / 3) t view:flat s sort:name↑ e args:full ^t type:all ^s scope:all … + Address Size Bind Type Name △  +0x0000000000401000 32 GLOBAL FUNC _start +0x0000000000401020 11 GLOBAL FUNC helper +0x0000000000402000 12 GLOBAL OBJECT msg + + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Find   │ + │ │ + │ search the whole binary for the value under the caret │ + │ │ + │  1 Symbol _start in symbols  │ + │ 2 Section  .text in sections │ + │ 3 Address  0x401000 in address │ + │ │ + │ ↑/↓ select · ↵ or digit search · c copy value · Esc cancel │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · e args · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_goto.frame b/internal/ui/testdata/golden-lite/modal_goto.frame new file mode 100644 index 0000000..240a0ad --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_goto.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Jump to   ││ +││ ││ +││  all   symbols   sections   strings   libraries   address  ││ +││ → 0 ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ type to search — a symbol, section or address ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ scope · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_goto_results.frame b/internal/ui/testdata/golden-lite/modal_goto_results.frame new file mode 100644 index 0000000..96f328a --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_goto_results.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Jump to   ││ +││ ││ +││  all   symbols   sections   strings   libraries   address  ││ +││ → t  ││ +││ ││ +││  Symbols 0x0000000000401000 _start  ││ +││ Sections  0x0000000000401000 .text ││ +││ Sections  0x0000000000402000 .rodata ││ +││ Sections  0x0000000000403000 .data ││ +││ Sections  .symtab ││ +││ Sections  .strtab ││ +││ Sections  .shstrtab ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ scope · Esc cancel (1/7) ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_header.frame b/internal/ui/testdata/golden-lite/modal_header.frame new file mode 100644 index 0000000..1cd8446 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_header.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.e╭──────────────────────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│  IDENTITY│   ELF header   │────────── │ +│ Path │ │ │ +│ Format│ e_ident  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 │ │ +│ Class │ Class  ELFCLASS64 │ │ +│ Data │ Data  ELFDATA2LSB │ │ +│ OS/ABI│ Version  EV_CURRENT │ │ +│ Type │ OS/ABI  ELFOSABI_NONE │ │ +│ Machin│ ABI version  0 │ │ +│ Entry │ Type  ET_EXEC │ │ +│ │ Machine  EM_X86_64 │ │ +│  REQUIREM│ Entry  0x401000 │────────── │ +│ CPU / │ Program header off  0x40 │ │ +│ Linkin│ Section header off  0x40a8 │ │ +│ CPU fe│ Flags  0x0 │ │ +│ │ Header size  64 │ │ +│  OVERVIEW│ Program headers  1 × 56 bytes │────────── │ +│ File s│ Section headers  7 × 64 bytes │ │ +│ Mapped│ Section str index  6 │ │ +│ Code s│   ── program headers ── │ │ +│ Word s│ PT_LOAD  r-x vaddr 0x400000 filesz 0x4268 memsz 0x4268 │ │ +│ Progra│ │ │ +│ │ ↑/↓ scroll · Esc/⇧H close │ │ +│  CONTENTS│ │────────── │ +│ Sectio╰──────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_help.frame b/internal/ui/testdata/golden-lite/modal_help.frame new file mode 100644 index 0000000..7c913d1 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_help.frame @@ -0,0 +1,30 @@ +  ExEx   ╭────────────────────────────────────────────────────────────────────────────────╮ +╭────────│ │────────╮ +│ ▸ tin│   Keybindings   │ │ +│ │ │ │ +│  IDENT│ GLOBAL ───────────────────────────────────────────────────────────────────── │─────── │ +│ Pat│ 1–9 · 0  switch view (0 = relocations) │ │ +│ For│ g  goto anything (symbol/section/string/lib/addr · ⇥ scope) │ │ +│ Cla│ d/h/m  go to the caret address in disasm / hex / raw │ │ +│ Dat│ ␣ · >  open the caret address in another view (menu) │ │ +│ OS/│ f  find the value under the caret across the binary │ │ +│ Typ│ l  search the binary for anything you type │ │ +│ Mac│ / · n/N  search or filter this view · next / prev match │ │ +│ Ent│ w  toggle long-line wrap │ │ +│ │ ⇧h  raw file header (ELF e_* / Mach-O load cmds / PE) │ │ +│  REQUI│ ⇧f  CPU features required (SSE/AVX/NEON · baseline) │─────── │ +│ CPU│ ^o  back (return from an opened dependency) │ │ +│ Lin│ ,  settings (theme, wrap, …) │ │ +│ CPU│ ?  this help │ │ +│ │ q · ^c  quit │ │ +│  OVERV│ │─────── │ +│ Fil│ EVERY LIST ───────────────────────────────────────────────────────────────── │ │ +│ Map│ ↑/↓ · ↵  move the cursor · open / jump │ │ +│ Cod│ PgUp/PgDn · [ ]  page (^↑/^↓) │ │ +│ Wor│ Home/End · ^a/^e first / last row │ │ +│ Pro│ t  switch the view's layout (table ↔ tree ↔ …) │ │ +│ │ s/r  sort · reverse │ │ +│  CONTE│ │─────── │ +│ Sec│ ↑/↓ scroll · 1–22 of 90 · Esc/any key closes │ │ +╰────────│ │────────╯ + ↵ disasm╰────────────────────────────────────────────────────────────────────────────────╯ · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_jump.frame b/internal/ui/testdata/golden-lite/modal_jump.frame new file mode 100644 index 0000000..5d8ac19 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_jump.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000 mov $0x1,%rax + 0x0000000000401007 48c7c701000000 mov $0x1,%rdi + 0x000000000040100e e80d000000 callq 0x401020 helper + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Open   0x401000 │ + │ _start · .text │ + │ │ + │  ▸ 5 Hex 0x401000 .text  │ + │ ▸ 6 Raw offset 0x1000 │ + │ ▸ 3 Symbols _start │ + │ ▸ 2 Sections .text │ + │  · 7 Strings no string here  │ + │  · 0 Relocs no relocations  │ + │ │ + │ ↑/↓ select · ↵ or digit open · Esc cancel │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + 0x0000000000401024 b82a000000 mov $0x2a,%eax + 0x0000000000401029 5d pop %rbp + 0x000000000040102a c3 retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_search.frame b/internal/ui/testdata/golden-lite/modal_search.frame new file mode 100644 index 0000000..4036580 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_search.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   Search   ││ +││ Search this view ││ +││ ││ +││ / h ││ +││ ││ +││ mode ⟦auto⟧ case ⟦insensitive⟧ dir ⟦→ forward⟧ origin ⟦cursor⟧ ││ +││ ││ +││ ^T mode · ^I case · ^R dir · ^O origin · ↵ find · n/N next/prev · esc cancel ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_settings.frame b/internal/ui/testdata/golden-lite/modal_settings.frame new file mode 100644 index 0000000..df0fa39 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_settings.frame @@ -0,0 +1,30 @@ +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│   Settings   │ +│ │ +│ APPEARANCE │ +│  Theme ‹ nord ›  colour palette for the whole UI │ +│ Panel background ‹ off › solid fill behind the view panels │ +│ Wrap long lines ‹ off › soft-wrap rows wider than the window │ +│ │ +│ STARTUP │ +│ Open in view ‹ info › the view shown when a file loads │ +│ Disasm target ‹ lowest › where Disasm lands: entry/main/start/text/lowest │ +│ │ +│ LISTS & TREES │ +│ Symbols as tree ‹ off › group symbols by their source path │ +│ Sources as tree ‹ off › nest source files into folders │ +│ Libraries as tree ‹ off › group libraries by directory │ +│ Start collapsed ‹ off › open trees folded to the top level │ +│ │ +│ SYMBOLS & NAMES │ +│ Abbreviate args ‹ off › shorten long demangled signatures │ +│ Demangle symbols ‹ on › show foo::bar() vs raw _ZN3foo… │ +│ │ +│ DISASSEMBLY │ +│ Show raw bytes ‹ on › the machine-code byte column │ +│ Show annotations ‹ on › inline target, reloc & string notes │ +│ │ +│ ↑/↓ field · ←/→ change · Enter save · Esc cancel (1/16) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_settings_scrolled.frame b/internal/ui/testdata/golden-lite/modal_settings_scrolled.frame new file mode 100644 index 0000000..ee8b938 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_settings_scrolled.frame @@ -0,0 +1,30 @@ +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│   Settings   │ +│ │ +│ STARTUP │ +│ Open in view ‹ info › the view shown when a file loads │ +│ Disasm target ‹ lowest › where Disasm lands: entry/main/start/text/lowest │ +│ │ +│ LISTS & TREES │ +│ Symbols as tree ‹ off › group symbols by their source path │ +│ Sources as tree ‹ off › nest source files into folders │ +│ Libraries as tree ‹ off › group libraries by directory │ +│ Start collapsed ‹ off › open trees folded to the top level │ +│ │ +│ SYMBOLS & NAMES │ +│ Abbreviate args ‹ off › shorten long demangled signatures │ +│ Demangle symbols ‹ on › show foo::bar() vs raw _ZN3foo… │ +│ │ +│ DISASSEMBLY │ +│ Show raw bytes ‹ on › the machine-code byte column │ +│ Show annotations ‹ on › inline target, reloc & string notes │ +│ Byte spacing ‹ compact › space-separated vs packed bytes │ +│ │ +│ ADDRESSES & HEX │ +│  Address width ‹ full ›  narrow 64-bit addrs when the top half is 0 │ +│ Hex bytes / row ‹ 16 › bytes per row in the Hex & Raw views │ +│ │ +│ ↑/↓ field · ←/→ change · Enter save · Esc cancel (15/16) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_syscalls.frame b/internal/ui/testdata/golden-lite/modal_syscalls.frame new file mode 100644 index 0000000..59ad247 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_syscalls.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮  +=│ │ + │   System calls   │ + │ │ + │ scope  function   binary   unique   full+libs  │ + │ / name · #num · symbol  1 │ + │ named · num-only · vdso · unresolved sort: number↑ │ + │ │ + │  0x0000000000401013 write _start  │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (1/1) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_syscalls_full_longnames.frame b/internal/ui/testdata/golden-lite/modal_syscalls_full_longnames.frame new file mode 100644 index 0000000..08e0402 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_syscalls_full_longnames.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮  +=│ │ + │   System calls   │ + │ │ + │ scope  function   binary   unique   full+libs  │ + │ / name · #num · symbol  6 │ + │ named · num-only · vdso · unresolved sort: number↑ │ + │ │ + │  1× #170 csops_audittoken libsystem_kernel.dylib svc #0x80  │ + │ 1× #173 waitid  libsystem_kernel.dylib svc #0x80 │ + │ 1× #177 kdebug_typefilter  libsystem_kernel.dylib svc #0x80 │ + │ 1× #178 kdebug_trace_string libsystem_kernel.dylib svc #0x80 │ + │ 1× #179 kdebug_trace64  libsystem_kernel.dylib svc #0x80 │ + │ 1× #180 kdebug_trace  libsystem_kernel.dylib svc #0x80 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (1/6) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/modal_xref.frame b/internal/ui/testdata/golden-lite/modal_xref.frame new file mode 100644 index 0000000..84aaf14 --- /dev/null +++ b/internal/ui/testdata/golden-lite/modal_xref.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000 mov $0x1,%rax + 0x0000000000401007 48c7c701000000 mov $0x1,%rdi + 0x000000000040100e e80d000000 callq 0x401020 helper + 0x0000000000401013 0f05 syscall + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Cross-references   │ + │ helper │ + │ / location · text · 0xaddr  2 │ + │ call · jump · load sort: address↑ │ + │ │ + │  0x0000000000401000 _start mov rax, 1  │ + │ 0x000000000040100e _start call 0x401020 │ + │ │ + │ ↑/↓ select · ↵ jump · s/r sort · / filter · Esc close (1/2) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + 0x0000000000401021 4889e5 mov %rsp,%rbp + 0x0000000000401024 b82a000000 mov $0x2a,%eax + 0x0000000000401029 5d pop %rbp + 0x000000000040102a c3 retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_disasm.frame b/internal/ui/testdata/golden-lite/view_disasm.frame new file mode 100644 index 0000000..df8b2f9 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_disasm.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000 mov $0x1,%rax + 0x0000000000401007 48c7c701000000 mov $0x1,%rdi + 0x000000000040100e e80d000000 callq 0x401020 helper + 0x0000000000401013 0f05 syscall + 0x0000000000401015 c3 retq + 0x0000000000401016 90 nop + 0x0000000000401017 90 nop + 0x0000000000401018 90 nop + 0x0000000000401019 90 nop + 0x000000000040101a 90 nop + 0x000000000040101b 90 nop + 0x000000000040101c 90 nop + 0x000000000040101d 90 nop + 0x000000000040101e 90 nop + 0x000000000040101f 90 nop + : + 0x0000000000401020 55 push %rbp + 0x0000000000401021 4889e5 mov %rsp,%rbp + 0x0000000000401024 b82a000000 mov $0x2a,%eax + 0x0000000000401029 5d pop %rbp + 0x000000000040102a c3 retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_hex.frame b/internal/ui/testdata/golden-lite/view_hex.frame new file mode 100644 index 0000000..54d527f --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_hex.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4  5·Hex  6 7 8 9 0 + .text @ 0x0000000000401000 · 105 bytes across 3 mapped sections  +========================================= .text ========================================= + 0x0000000000401000 48 c7 c0 01 00 00 00 48 c7 c7 01 00 00 00 e8 0d |H......H........| [_start] + 0x0000000000401010 00 00 00 0f 05 c3 90 90 90 90 90 90 90 90 90 90 |................| [_start] + 0x0000000000401020 55 48 89 e5 b8 2a 00 00 00 5d c3 |UH...*...]. | [helper] +======================================== .rodata ======================================== + 0x0000000000402000 68 65 6c 6c 6f 20 77 6f 72 6c 64 00 65 78 65 78 |hello world.exex| [msg] + 0x0000000000402010 20 67 6f 6c 64 65 6e 20 66 69 78 74 75 72 65 00 | golden fixture.| + 0x0000000000402020 2f 74 6d 70 2f 73 61 6d 70 6c 65 2e 63 00 |/tmp/sample.c. | +========================================= .data ========================================= + 0x0000000000403000 00 20 40 00 00 00 00 00 de ad be ef 00 11 22 33 |. @..........."3| .data + + + + + + + + + + + + + + + + + + ↵ follow ptr · [ ] section · d/m disasm/raw · ␣ open in… · f find · i inspect … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_info.frame b/internal/ui/testdata/golden-lite/view_info.frame new file mode 100644 index 0000000..babb192 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_info.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│ OS/ABI  ELFOSABI_NONE │ +│ Type  ET_EXEC (runnable program) │ +│ Machine  EM_X86_64 │ +│ Entry  0x0000000000401000 _start ↵ disassemble │ +│ │ +│  REQUIREMENTS ───────────────────────────────────────────────────────────────────────────────── │ +│ CPU / arch  x86-64 · 64-bit · little-endian │ +│ Linking  statically linked │ +│ CPU features  press ⇧F to detect (SSE / AVX / NEON / …) │ +│ │ +│  OVERVIEW ───────────────────────────────────────────────────────────────────────────────────── │ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_libs.frame b/internal/ui/testdata/golden-lite/view_libs.frame new file mode 100644 index 0000000..ebbb988 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_libs.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7  8·Libs  9 0 + + + + + + + + + + + + + no dynamic libraries — this binary is statically linked or has no DT_NEEDED entries + + Static-linked: yes + Libc: unknown + + + + + + + + + + + + + ↵ imports · o open · t view · r rev · ^p avail · / filter · ⇧s copy │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_raw.frame b/internal/ui/testdata/golden-lite/view_raw.frame new file mode 100644 index 0000000..d5b6420 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_raw.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5  6·Raw  7 8 9 0 + raw file · 17000 bytes · file offsets  + 0x0000000000000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| + 0x0000000000000010 02 00 3e 00 01 00 00 00 00 10 40 00 00 00 00 00 |..>.......@.....| + 0x0000000000000020 40 00 00 00 00 00 00 00 a8 40 00 00 00 00 00 00 |@........@......| + 0x0000000000000030 00 00 00 00 40 00 38 00 01 00 40 00 07 00 06 00 |....@.8...@.....| + 0x0000000000000040 01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000050 00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00 |..@.......@.....| + 0x0000000000000060 68 42 00 00 00 00 00 00 68 42 00 00 00 00 00 00 |hB......hB......| + 0x0000000000000070 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000090 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000b0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000c0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000100 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000110 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000120 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000130 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000140 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000150 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000160 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000170 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000180 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000190 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000001a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + ↵ follow ptr · [ ] section · d disasm · ␣ open in… · f find · i inspect … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_relocs.frame b/internal/ui/testdata/golden-lite/view_relocs.frame new file mode 100644 index 0000000..0af327b --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_relocs.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7 8 9  0·Relocs  + + + + + + + + + + + + + + No relocations — this binary is fully resolved (statically + linked, or no dynamic fixups). + + + + + + + + + + + + + + ↵ hex · d/h/m go to · ␣ open in… · f find · e args · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_sections.frame b/internal/ui/testdata/golden-lite/view_sections.frame new file mode 100644 index 0000000..7fe0925 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_sections.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1  2·Sections  3 4 5 6 7 8 9 0 +/ sections (7 / 7) t view:sections s sort:index↑ ^t type:all ^f flags:all + # △ Name Type Addr Size Flags  + 0 NULL 0x0000000000000000 0 - +  1 .text  PROGBITS  0x0000000000401000 43  AX +  2 .rodata  PROGBITS  0x0000000000402000 46  A +  3 .data  PROGBITS  0x0000000000403000 16  AW +  4 .symtab  SYMTAB  0x0000000000000000 96  - +  5 .strtab  STRTAB  0x0000000000000000 19  - +  6 .shstrtab  STRTAB  0x0000000000000000 47  - + + + + + + + + + + + + + + + + + + + + ↵ open · d/h/m go to · ␣ open in… · f find · ⇧H header · t view · s/r sort/rev │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_sections_background.frame b/internal/ui/testdata/golden-lite/view_sections_background.frame new file mode 100644 index 0000000..4c4381f --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_sections_background.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1  2·Sections  3 4 5 6 7 8 9 0  +/ sections (7 / 7) t view:sections s sort:index↑ ^t type:all ^f flags:all  + # △ Name Type Addr Size Flags  + 0 NULL 0x0000000000000000 0 -  +  1 .text  PROGBITS  0x0000000000401000 43  AX  +  2 .rodata  PROGBITS  0x0000000000402000 46  A  +  3 .data  PROGBITS  0x0000000000403000 16  AW  +  4 .symtab  SYMTAB  0x0000000000000000 96  -  +  5 .strtab  STRTAB  0x0000000000000000 19  -  +  6 .shstrtab  STRTAB  0x0000000000000000 47  -  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + ↵ open · d/h/m go to · ␣ open in… · f find · ⇧H header · t view · s/r sort/rev │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_sources.frame b/internal/ui/testdata/golden-lite/view_sources.frame new file mode 100644 index 0000000..453d379 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_sources.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7 8  9·Sources  0 + + + + + + + + + + + + + + no debug info — the Sources view needs DWARF (build with -g, + or place a .dSYM / .debug sidecar next to the binary) + + + + + + + + + + + + + + ↵ open · t view · s/r sort/rev · ^p avail · / filter · ⇧s copy │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_strings.frame b/internal/ui/testdata/golden-lite/view_strings.frame new file mode 100644 index 0000000..b5c51f6 --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_strings.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6  7·Strings  8 9 0 +/ strings (11 / 11) t view:table s sort:offset↑ ^s section:all ^p paths:off + Offset △ Address Section String  + 0x2000 0x0000000000402000 .rodata hello world + 0x200c  0x000000000040200c .rodata  exex golden fixture + 0x2020  0x0000000000402020 .rodata  /tmp/sample.c + 0x4061    .strtab  _start + 0x4068    .strtab  helper + 0x4074    .shstrtab  .text + 0x407a    .shstrtab  .rodata + 0x4082    .shstrtab  .data + 0x4088    .shstrtab  .symtab + 0x4090    .shstrtab  .strtab + 0x4098    .shstrtab  .shstrtab + + + + + + + + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden-lite/view_symbols.frame b/internal/ui/testdata/golden-lite/view_symbols.frame new file mode 100644 index 0000000..8f3ec9a --- /dev/null +++ b/internal/ui/testdata/golden-lite/view_symbols.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2  3·Symbols  4 5 6 7 8 9 0 +/ symbols (3 / 3) t view:flat s sort:name↑ e args:full ^t type:all ^s scope:all … + Address Size Bind Type Name △  +0x0000000000401000 32 GLOBAL FUNC _start +0x0000000000401020 11 GLOBAL FUNC helper +0x0000000000402000 12 GLOBAL OBJECT msg + + + + + + + + + + + + + + + + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · e args · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_cpufeatures.frame b/internal/ui/testdata/golden/modal_cpufeatures.frame new file mode 100644 index 0000000..89feca6 --- /dev/null +++ b/internal/ui/testdata/golden/modal_cpufeatures.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   CPU features   ││ +││ x86-64-v3 · 12345 instructions scanned ││ +││ ││ +││  AVX 42 × first at 0x0000000000401000  ││ +││ AVX512F  1 × first at 0x00000000004010ff ││ +││ BMI2  300 × first at 0x0000000000402000 ││ +││ SSE2  7 × first at 0x0000000000401020 ││ +││ ││ +││ ↑/↓ select · ↵ jump to first use · Esc close (1/4) ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_cpufeatures_empty.frame b/internal/ui/testdata/golden/modal_cpufeatures_empty.frame new file mode 100644 index 0000000..fd0f78a --- /dev/null +++ b/internal/ui/testdata/golden/modal_cpufeatures_empty.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│ OS/ABI  ELFOSABI_NONE │ +│ Type ╭───────────────────────────────────────────────────────────────╮ │ +│ Machine │ │ │ +│ Entry │   CPU features   │ │ +│ │ 99 instructions scanned │ │ +│  REQUIREMENTS │ │──────────────── │ +│ CPU / arch │ only base instructions — no optional CPU features detected │ │ +│ Linking │ │ │ +│ CPU feature│ ↑/↓ select · ↵ jump to first use · Esc close (0/0) │ │ +│ │ │ │ +│  OVERVIEW ────╰───────────────────────────────────────────────────────────────╯──────────────── │ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_find_query.frame b/internal/ui/testdata/golden/modal_find_query.frame new file mode 100644 index 0000000..9949562 --- /dev/null +++ b/internal/ui/testdata/golden/modal_find_query.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   Search the binary   ││ +││ ││ +││ search s ││ +││ ││ +││ case-insensitive (^i) ││ +││ ││ +││ ↵ search disasm · data · strings · relocs · Esc cancel ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_find_results.frame b/internal/ui/testdata/golden/modal_find_results.frame new file mode 100644 index 0000000..7d89aff --- /dev/null +++ b/internal/ui/testdata/golden/modal_find_results.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Find _start   ✓ 3 found ││ +││ ││ +││  all 3   disasm 1   data 1   strings 1   relocs  ││ +││ / filter results  3 ││ +││ ││ +││  disasm 0x000000000040100e call 0x401020 _start  ││ +││ data  @0x000000000003000 pointer word .data ││ +││ strings  0x0000000000402000 hello world .rodata ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ view · / filter · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_find_results_searching.frame b/internal/ui/testdata/golden/modal_find_results_searching.frame new file mode 100644 index 0000000..d346323 --- /dev/null +++ b/internal/ui/testdata/golden/modal_find_results_searching.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Find _start   ● searching 4 sources ││ +││ ││ +││  all 0   disasm   data   strings   relocs  ││ +││ / filter results  0 ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ searching … ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ view · / filter · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_find_seeds.frame b/internal/ui/testdata/golden/modal_find_seeds.frame new file mode 100644 index 0000000..0bad18a --- /dev/null +++ b/internal/ui/testdata/golden/modal_find_seeds.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2  3·Symbols  4 5 6 7 8 9 0 +/ symbols (3 / 3) t view:flat s sort:name↑ e args:full ^t type:all ^s scope:all … + Address Size Bind Type Name △  +0x0000000000401000 32 GLOBAL FUNC _start +0x0000000000401020 11 GLOBAL FUNC helper +0x0000000000402000 12 GLOBAL OBJECT msg + + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Find   │ + │ │ + │ search the whole binary for the value under the caret │ + │ │ + │  1 Symbol _start in symbols  │ + │ 2 Section  .text in sections │ + │ 3 Address  0x401000 in address │ + │ │ + │ ↑/↓ select · ↵ or digit search · c copy value · Esc cancel │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · e args · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_goto.frame b/internal/ui/testdata/golden/modal_goto.frame new file mode 100644 index 0000000..240a0ad --- /dev/null +++ b/internal/ui/testdata/golden/modal_goto.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Jump to   ││ +││ ││ +││  all   symbols   sections   strings   libraries   address  ││ +││ → 0 ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ type to search — a symbol, section or address ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ scope · Esc cancel ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_goto_results.frame b/internal/ui/testdata/golden/modal_goto_results.frame new file mode 100644 index 0000000..96f328a --- /dev/null +++ b/internal/ui/testdata/golden/modal_goto_results.frame @@ -0,0 +1,30 @@ + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +╭│ │╮ +││   Jump to   ││ +││ ││ +││  all   symbols   sections   strings   libraries   address  ││ +││ → t  ││ +││ ││ +││  Symbols 0x0000000000401000 _start  ││ +││ Sections  0x0000000000401000 .text ││ +││ Sections  0x0000000000402000 .rodata ││ +││ Sections  0x0000000000403000 .data ││ +││ Sections  .symtab ││ +││ Sections  .strtab ││ +││ Sections  .shstrtab ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ↑/↓ select · ↵ jump · ⇥ scope · Esc cancel (1/7) ││ +││ ││ +╰╰────────────────────────────────────────────────────────────────────────────────────────────────╯╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_header.frame b/internal/ui/testdata/golden/modal_header.frame new file mode 100644 index 0000000..1cd8446 --- /dev/null +++ b/internal/ui/testdata/golden/modal_header.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.e╭──────────────────────────────────────────────────────────────────────────╮ │ +│ │ │ │ +│  IDENTITY│   ELF header   │────────── │ +│ Path │ │ │ +│ Format│ e_ident  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 │ │ +│ Class │ Class  ELFCLASS64 │ │ +│ Data │ Data  ELFDATA2LSB │ │ +│ OS/ABI│ Version  EV_CURRENT │ │ +│ Type │ OS/ABI  ELFOSABI_NONE │ │ +│ Machin│ ABI version  0 │ │ +│ Entry │ Type  ET_EXEC │ │ +│ │ Machine  EM_X86_64 │ │ +│  REQUIREM│ Entry  0x401000 │────────── │ +│ CPU / │ Program header off  0x40 │ │ +│ Linkin│ Section header off  0x40a8 │ │ +│ CPU fe│ Flags  0x0 │ │ +│ │ Header size  64 │ │ +│  OVERVIEW│ Program headers  1 × 56 bytes │────────── │ +│ File s│ Section headers  7 × 64 bytes │ │ +│ Mapped│ Section str index  6 │ │ +│ Code s│   ── program headers ── │ │ +│ Word s│ PT_LOAD  r-x vaddr 0x400000 filesz 0x4268 memsz 0x4268 │ │ +│ Progra│ │ │ +│ │ ↑/↓ scroll · Esc/⇧H close │ │ +│  CONTENTS│ │────────── │ +│ Sectio╰──────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_help.frame b/internal/ui/testdata/golden/modal_help.frame new file mode 100644 index 0000000..7c913d1 --- /dev/null +++ b/internal/ui/testdata/golden/modal_help.frame @@ -0,0 +1,30 @@ +  ExEx   ╭────────────────────────────────────────────────────────────────────────────────╮ +╭────────│ │────────╮ +│ ▸ tin│   Keybindings   │ │ +│ │ │ │ +│  IDENT│ GLOBAL ───────────────────────────────────────────────────────────────────── │─────── │ +│ Pat│ 1–9 · 0  switch view (0 = relocations) │ │ +│ For│ g  goto anything (symbol/section/string/lib/addr · ⇥ scope) │ │ +│ Cla│ d/h/m  go to the caret address in disasm / hex / raw │ │ +│ Dat│ ␣ · >  open the caret address in another view (menu) │ │ +│ OS/│ f  find the value under the caret across the binary │ │ +│ Typ│ l  search the binary for anything you type │ │ +│ Mac│ / · n/N  search or filter this view · next / prev match │ │ +│ Ent│ w  toggle long-line wrap │ │ +│ │ ⇧h  raw file header (ELF e_* / Mach-O load cmds / PE) │ │ +│  REQUI│ ⇧f  CPU features required (SSE/AVX/NEON · baseline) │─────── │ +│ CPU│ ^o  back (return from an opened dependency) │ │ +│ Lin│ ,  settings (theme, wrap, …) │ │ +│ CPU│ ?  this help │ │ +│ │ q · ^c  quit │ │ +│  OVERV│ │─────── │ +│ Fil│ EVERY LIST ───────────────────────────────────────────────────────────────── │ │ +│ Map│ ↑/↓ · ↵  move the cursor · open / jump │ │ +│ Cod│ PgUp/PgDn · [ ]  page (^↑/^↓) │ │ +│ Wor│ Home/End · ^a/^e first / last row │ │ +│ Pro│ t  switch the view's layout (table ↔ tree ↔ …) │ │ +│ │ s/r  sort · reverse │ │ +│  CONTE│ │─────── │ +│ Sec│ ↑/↓ scroll · 1–22 of 90 · Esc/any key closes │ │ +╰────────│ │────────╯ + ↵ disasm╰────────────────────────────────────────────────────────────────────────────────╯ · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_jump.frame b/internal/ui/testdata/golden/modal_jump.frame new file mode 100644 index 0000000..1d1ee03 --- /dev/null +++ b/internal/ui/testdata/golden/modal_jump.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000  mov $0x1,%rax + 0x0000000000401007 48c7c701000000  mov $0x1,%rdi + 0x000000000040100e e80d000000  callq 0x401020 helper + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Open   0x401000 │ + │ _start · .text │ + │ │ + │  ▸ 5 Hex 0x401000 .text  │ + │ ▸ 6 Raw offset 0x1000 │ + │ ▸ 3 Symbols _start │ + │ ▸ 2 Sections .text │ + │  · 7 Strings no string here  │ + │  · 0 Relocs no relocations  │ + │ │ + │ ↑/↓ select · ↵ or digit open · Esc cancel │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + 0x0000000000401024 b82a000000  mov $0x2a,%eax + 0x0000000000401029 5d  pop %rbp + 0x000000000040102a c3  retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_search.frame b/internal/ui/testdata/golden/modal_search.frame new file mode 100644 index 0000000..4036580 --- /dev/null +++ b/internal/ui/testdata/golden/modal_search.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│╭────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││ ││ +││   Search   ││ +││ Search this view ││ +││ ││ +││ / h ││ +││ ││ +││ mode ⟦auto⟧ case ⟦insensitive⟧ dir ⟦→ forward⟧ origin ⟦cursor⟧ ││ +││ ││ +││ ^T mode · ^I case · ^R dir · ^O origin · ↵ find · n/N next/prev · esc cancel ││ +││ ││ +│╰────────────────────────────────────────────────────────────────────────────────────────────────╯│ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_settings.frame b/internal/ui/testdata/golden/modal_settings.frame new file mode 100644 index 0000000..df0fa39 --- /dev/null +++ b/internal/ui/testdata/golden/modal_settings.frame @@ -0,0 +1,30 @@ +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│   Settings   │ +│ │ +│ APPEARANCE │ +│  Theme ‹ nord ›  colour palette for the whole UI │ +│ Panel background ‹ off › solid fill behind the view panels │ +│ Wrap long lines ‹ off › soft-wrap rows wider than the window │ +│ │ +│ STARTUP │ +│ Open in view ‹ info › the view shown when a file loads │ +│ Disasm target ‹ lowest › where Disasm lands: entry/main/start/text/lowest │ +│ │ +│ LISTS & TREES │ +│ Symbols as tree ‹ off › group symbols by their source path │ +│ Sources as tree ‹ off › nest source files into folders │ +│ Libraries as tree ‹ off › group libraries by directory │ +│ Start collapsed ‹ off › open trees folded to the top level │ +│ │ +│ SYMBOLS & NAMES │ +│ Abbreviate args ‹ off › shorten long demangled signatures │ +│ Demangle symbols ‹ on › show foo::bar() vs raw _ZN3foo… │ +│ │ +│ DISASSEMBLY │ +│ Show raw bytes ‹ on › the machine-code byte column │ +│ Show annotations ‹ on › inline target, reloc & string notes │ +│ │ +│ ↑/↓ field · ←/→ change · Enter save · Esc cancel (1/16) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_settings_scrolled.frame b/internal/ui/testdata/golden/modal_settings_scrolled.frame new file mode 100644 index 0000000..ee8b938 --- /dev/null +++ b/internal/ui/testdata/golden/modal_settings_scrolled.frame @@ -0,0 +1,30 @@ +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│   Settings   │ +│ │ +│ STARTUP │ +│ Open in view ‹ info › the view shown when a file loads │ +│ Disasm target ‹ lowest › where Disasm lands: entry/main/start/text/lowest │ +│ │ +│ LISTS & TREES │ +│ Symbols as tree ‹ off › group symbols by their source path │ +│ Sources as tree ‹ off › nest source files into folders │ +│ Libraries as tree ‹ off › group libraries by directory │ +│ Start collapsed ‹ off › open trees folded to the top level │ +│ │ +│ SYMBOLS & NAMES │ +│ Abbreviate args ‹ off › shorten long demangled signatures │ +│ Demangle symbols ‹ on › show foo::bar() vs raw _ZN3foo… │ +│ │ +│ DISASSEMBLY │ +│ Show raw bytes ‹ on › the machine-code byte column │ +│ Show annotations ‹ on › inline target, reloc & string notes │ +│ Byte spacing ‹ compact › space-separated vs packed bytes │ +│ │ +│ ADDRESSES & HEX │ +│  Address width ‹ full ›  narrow 64-bit addrs when the top half is 0 │ +│ Hex bytes / row ‹ 16 › bytes per row in the Hex & Raw views │ +│ │ +│ ↑/↓ field · ←/→ change · Enter save · Esc cancel (15/16) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_syscalls.frame b/internal/ui/testdata/golden/modal_syscalls.frame new file mode 100644 index 0000000..b892c5e --- /dev/null +++ b/internal/ui/testdata/golden/modal_syscalls.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮  +=│ │ + │   System calls   │ + │ │ + │ scope  function   binary   unique   full+libs  │ + │ / name · #num · symbol  1 │ + │ named · num-only · vdso · unresolved sort: number↑ │ + │ │ + │  0x0000000000401013 write _start  │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (1/1) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_syscalls_full_longnames.frame b/internal/ui/testdata/golden/modal_syscalls_full_longnames.frame new file mode 100644 index 0000000..6d28ce0 --- /dev/null +++ b/internal/ui/testdata/golden/modal_syscalls_full_longnames.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮  +=│ │ + │   System calls   │ + │ │ + │ scope  function   binary   unique   full+libs  │ + │ / name · #num · symbol  6 │ + │ named · num-only · vdso · unresolved sort: number↑ │ + │ │ + │  1× #170 csops_audittoken libsystem_kernel.dylib svc #0x80  │ + │ 1× #173 waitid  libsystem_kernel.dylib svc #0x80 │ + │ 1× #177 kdebug_typefilter  libsystem_kernel.dylib svc #0x80 │ + │ 1× #178 kdebug_trace_string libsystem_kernel.dylib svc #0x80 │ + │ 1× #179 kdebug_trace64  libsystem_kernel.dylib svc #0x80 │ + │ 1× #180 kdebug_trace  libsystem_kernel.dylib svc #0x80 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ↑/↓ select · ↵ jump · t scope · s/r sort · / filter · Esc close (1/6) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/modal_xref.frame b/internal/ui/testdata/golden/modal_xref.frame new file mode 100644 index 0000000..2663ebe --- /dev/null +++ b/internal/ui/testdata/golden/modal_xref.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000  mov $0x1,%rax + 0x0000000000401007 48c7c701000000  mov $0x1,%rdi + 0x000000000040100e e80d000000  callq 0x401020 helper + 0x0000000000401013 0f05 syscall + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │   Cross-references   │ + │ helper │ + │ / location · text · 0xaddr  2 │ + │ call · jump · load sort: address↑ │ + │ │ + │  0x0000000000401000 _start mov rax, 1  │ + │ 0x000000000040100e _start call 0x401020 │ + │ │ + │ ↑/↓ select · ↵ jump · s/r sort · / filter · Esc close (1/2) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + 0x0000000000401021 4889e5  mov %rsp,%rbp + 0x0000000000401024 b82a000000  mov $0x2a,%eax + 0x0000000000401029 5d  pop %rbp + 0x000000000040102a c3  retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_disasm.frame b/internal/ui/testdata/golden/view_disasm.frame new file mode 100644 index 0000000..08e5e17 --- /dev/null +++ b/internal/ui/testdata/golden/view_disasm.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3  4·Disasm  5 6 7 8 9 0 + _start @ 0x0000000000401000  +============================================== .text ============================================== + <_start>: + 0x0000000000401000 48c7c001000000  mov $0x1,%rax + 0x0000000000401007 48c7c701000000  mov $0x1,%rdi + 0x000000000040100e e80d000000  callq 0x401020 helper + 0x0000000000401013 0f05 syscall + 0x0000000000401015 c3  retq + 0x0000000000401016 90  nop + 0x0000000000401017 90  nop + 0x0000000000401018 90  nop + 0x0000000000401019 90  nop + 0x000000000040101a 90  nop + 0x000000000040101b 90  nop + 0x000000000040101c 90  nop + 0x000000000040101d 90  nop + 0x000000000040101e 90  nop + 0x000000000040101f 90  nop + : + 0x0000000000401020 55  push %rbp + 0x0000000000401021 4889e5  mov %rsp,%rbp + 0x0000000000401024 b82a000000  mov $0x2a,%eax + 0x0000000000401029 5d  pop %rbp + 0x000000000040102a c3  retq + + + + + ↵ follow · [ ] sym · ←/→ history · h/m hex/raw · ␣ open in… · f find · x xrefs │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_hex.frame b/internal/ui/testdata/golden/view_hex.frame new file mode 100644 index 0000000..54d527f --- /dev/null +++ b/internal/ui/testdata/golden/view_hex.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4  5·Hex  6 7 8 9 0 + .text @ 0x0000000000401000 · 105 bytes across 3 mapped sections  +========================================= .text ========================================= + 0x0000000000401000 48 c7 c0 01 00 00 00 48 c7 c7 01 00 00 00 e8 0d |H......H........| [_start] + 0x0000000000401010 00 00 00 0f 05 c3 90 90 90 90 90 90 90 90 90 90 |................| [_start] + 0x0000000000401020 55 48 89 e5 b8 2a 00 00 00 5d c3 |UH...*...]. | [helper] +======================================== .rodata ======================================== + 0x0000000000402000 68 65 6c 6c 6f 20 77 6f 72 6c 64 00 65 78 65 78 |hello world.exex| [msg] + 0x0000000000402010 20 67 6f 6c 64 65 6e 20 66 69 78 74 75 72 65 00 | golden fixture.| + 0x0000000000402020 2f 74 6d 70 2f 73 61 6d 70 6c 65 2e 63 00 |/tmp/sample.c. | +========================================= .data ========================================= + 0x0000000000403000 00 20 40 00 00 00 00 00 de ad be ef 00 11 22 33 |. @..........."3| .data + + + + + + + + + + + + + + + + + + ↵ follow ptr · [ ] section · d/m disasm/raw · ␣ open in… · f find · i inspect … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_info.frame b/internal/ui/testdata/golden/view_info.frame new file mode 100644 index 0000000..babb192 --- /dev/null +++ b/internal/ui/testdata/golden/view_info.frame @@ -0,0 +1,30 @@ +  ExEx   ELF  1·Info  2 3 4 5 6 7 8 9 0 +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ▸ tiny.elf ELF · x86-64 · ET_EXEC · static │ +│ │ +│  IDENTITY ───────────────────────────────────────────────────────────────────────────────────── │ +│ Path  testdata/tiny.elf │ +│ Format  ELF │ +│ Class  ELFCLASS64 │ +│ Data  ELFDATA2LSB │ +│ OS/ABI  ELFOSABI_NONE │ +│ Type  ET_EXEC (runnable program) │ +│ Machine  EM_X86_64 │ +│ Entry  0x0000000000401000 _start ↵ disassemble │ +│ │ +│  REQUIREMENTS ───────────────────────────────────────────────────────────────────────────────── │ +│ CPU / arch  x86-64 · 64-bit · little-endian │ +│ Linking  statically linked │ +│ CPU features  press ⇧F to detect (SSE / AVX / NEON / …) │ +│ │ +│  OVERVIEW ───────────────────────────────────────────────────────────────────────────────────── │ +│ File size  16.6 KiB (17000 bytes) │ +│ Mapped range  0x401000 – 0x403010 (8.0 KiB) │ +│ Code size  43 B (0% of file) │ +│ Word size  64-bit, little-endian │ +│ Program headers 1 │ +│ │ +│  CONTENTS ───────────────────────────────────────────────────────────────────────────────────── │ +│ Sections  7 (press 2) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + ↵ disasm entry · g goto · f find · l search all · , settings │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_libs.frame b/internal/ui/testdata/golden/view_libs.frame new file mode 100644 index 0000000..ebbb988 --- /dev/null +++ b/internal/ui/testdata/golden/view_libs.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7  8·Libs  9 0 + + + + + + + + + + + + + no dynamic libraries — this binary is statically linked or has no DT_NEEDED entries + + Static-linked: yes + Libc: unknown + + + + + + + + + + + + + ↵ imports · o open · t view · r rev · ^p avail · / filter · ⇧s copy │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_raw.frame b/internal/ui/testdata/golden/view_raw.frame new file mode 100644 index 0000000..d5b6420 --- /dev/null +++ b/internal/ui/testdata/golden/view_raw.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5  6·Raw  7 8 9 0 + raw file · 17000 bytes · file offsets  + 0x0000000000000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| + 0x0000000000000010 02 00 3e 00 01 00 00 00 00 10 40 00 00 00 00 00 |..>.......@.....| + 0x0000000000000020 40 00 00 00 00 00 00 00 a8 40 00 00 00 00 00 00 |@........@......| + 0x0000000000000030 00 00 00 00 40 00 38 00 01 00 40 00 07 00 06 00 |....@.8...@.....| + 0x0000000000000040 01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000050 00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00 |..@.......@.....| + 0x0000000000000060 68 42 00 00 00 00 00 00 68 42 00 00 00 00 00 00 |hB......hB......| + 0x0000000000000070 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000090 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000b0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000c0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000000f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000100 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000110 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000120 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000130 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000140 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000150 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000160 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000170 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000180 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x0000000000000190 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + 0x00000000000001a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| + ↵ follow ptr · [ ] section · d disasm · ␣ open in… · f find · i inspect … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_relocs.frame b/internal/ui/testdata/golden/view_relocs.frame new file mode 100644 index 0000000..0af327b --- /dev/null +++ b/internal/ui/testdata/golden/view_relocs.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7 8 9  0·Relocs  + + + + + + + + + + + + + + No relocations — this binary is fully resolved (statically + linked, or no dynamic fixups). + + + + + + + + + + + + + + ↵ hex · d/h/m go to · ␣ open in… · f find · e args · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_sections.frame b/internal/ui/testdata/golden/view_sections.frame new file mode 100644 index 0000000..7fe0925 --- /dev/null +++ b/internal/ui/testdata/golden/view_sections.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1  2·Sections  3 4 5 6 7 8 9 0 +/ sections (7 / 7) t view:sections s sort:index↑ ^t type:all ^f flags:all + # △ Name Type Addr Size Flags  + 0 NULL 0x0000000000000000 0 - +  1 .text  PROGBITS  0x0000000000401000 43  AX +  2 .rodata  PROGBITS  0x0000000000402000 46  A +  3 .data  PROGBITS  0x0000000000403000 16  AW +  4 .symtab  SYMTAB  0x0000000000000000 96  - +  5 .strtab  STRTAB  0x0000000000000000 19  - +  6 .shstrtab  STRTAB  0x0000000000000000 47  - + + + + + + + + + + + + + + + + + + + + ↵ open · d/h/m go to · ␣ open in… · f find · ⇧H header · t view · s/r sort/rev │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_sections_background.frame b/internal/ui/testdata/golden/view_sections_background.frame new file mode 100644 index 0000000..4c4381f --- /dev/null +++ b/internal/ui/testdata/golden/view_sections_background.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1  2·Sections  3 4 5 6 7 8 9 0  +/ sections (7 / 7) t view:sections s sort:index↑ ^t type:all ^f flags:all  + # △ Name Type Addr Size Flags  + 0 NULL 0x0000000000000000 0 -  +  1 .text  PROGBITS  0x0000000000401000 43  AX  +  2 .rodata  PROGBITS  0x0000000000402000 46  A  +  3 .data  PROGBITS  0x0000000000403000 16  AW  +  4 .symtab  SYMTAB  0x0000000000000000 96  -  +  5 .strtab  STRTAB  0x0000000000000000 19  -  +  6 .shstrtab  STRTAB  0x0000000000000000 47  -  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + ↵ open · d/h/m go to · ␣ open in… · f find · ⇧H header · t view · s/r sort/rev │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_sources.frame b/internal/ui/testdata/golden/view_sources.frame new file mode 100644 index 0000000..453d379 --- /dev/null +++ b/internal/ui/testdata/golden/view_sources.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6 7 8  9·Sources  0 + + + + + + + + + + + + + + no debug info — the Sources view needs DWARF (build with -g, + or place a .dSYM / .debug sidecar next to the binary) + + + + + + + + + + + + + + ↵ open · t view · s/r sort/rev · ^p avail · / filter · ⇧s copy │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_strings.frame b/internal/ui/testdata/golden/view_strings.frame new file mode 100644 index 0000000..b5c51f6 --- /dev/null +++ b/internal/ui/testdata/golden/view_strings.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2 3 4 5 6  7·Strings  8 9 0 +/ strings (11 / 11) t view:table s sort:offset↑ ^s section:all ^p paths:off + Offset △ Address Section String  + 0x2000 0x0000000000402000 .rodata hello world + 0x200c  0x000000000040200c .rodata  exex golden fixture + 0x2020  0x0000000000402020 .rodata  /tmp/sample.c + 0x4061    .strtab  _start + 0x4068    .strtab  helper + 0x4074    .shstrtab  .text + 0x407a    .shstrtab  .rodata + 0x4082    .shstrtab  .data + 0x4088    .shstrtab  .symtab + 0x4090    .shstrtab  .strtab + 0x4098    .shstrtab  .shstrtab + + + + + + + + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/testdata/golden/view_symbols.frame b/internal/ui/testdata/golden/view_symbols.frame new file mode 100644 index 0000000..8f3ec9a --- /dev/null +++ b/internal/ui/testdata/golden/view_symbols.frame @@ -0,0 +1,30 @@ +  ExEx   ELF 1 2  3·Symbols  4 5 6 7 8 9 0 +/ symbols (3 / 3) t view:flat s sort:name↑ e args:full ^t type:all ^s scope:all … + Address Size Bind Type Name △  +0x0000000000401000 32 GLOBAL FUNC _start +0x0000000000401020 11 GLOBAL FUNC helper +0x0000000000402000 12 GLOBAL OBJECT msg + + + + + + + + + + + + + + + + + + + + + + + + ↵ jump · d/h/m go to · ␣ open in… · f find · e args · t view · s/r sort/rev … │ ? help · q quit \ No newline at end of file diff --git a/internal/ui/textview.go b/internal/ui/textview.go index a6daa4a..748d1e5 100644 --- a/internal/ui/textview.go +++ b/internal/ui/textview.go @@ -13,6 +13,7 @@ package ui import ( "bytes" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -25,6 +26,7 @@ import ( "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/config" "github.com/rabarbra/exex/internal/syntax" + "github.com/rabarbra/exex/internal/ui/layout" ) // maxTextFileBytes bounds how much of a text file the viewer loads. @@ -96,7 +98,7 @@ func NewText(path string, cfg config.Config) (tea.Model, error) { // load reads path and recomputes the highlighted paths. func (m *textModel) load(path string) error { - data, err := os.ReadFile(path) + data, err := readFilePrefix(path, maxTextFileBytes+1) if err != nil { return err } @@ -201,7 +203,7 @@ func (m *textModel) handleKey(key string) (tea.Model, tea.Cmd) { } func (m *textModel) scroll(delta int) { - m.top = clamp(m.top+delta, 0, m.maxTop()) + m.top = layout.Clamp(m.top+delta, 0, m.maxTop()) } // back returns to the previously-viewed text file, or quits at the root. @@ -215,7 +217,7 @@ func (m *textModel) back() (tea.Model, tea.Cmd) { m.status = "back: " + err.Error() return m, nil } - m.top = clamp(prev.top, 0, m.maxTop()) + m.top = layout.Clamp(prev.top, 0, m.maxTop()) return m, nil } @@ -233,7 +235,7 @@ func (m *textModel) open(resolved string) (tea.Model, tea.Cmd) { nm.width, nm.height = m.width, m.height return nm, nm.Init() } - data, err := os.ReadFile(resolved) + data, err := readFilePrefix(resolved, 8192) if err != nil { m.status = "open: " + err.Error() return m, nil @@ -249,26 +251,35 @@ func (m *textModel) open(resolved string) (tea.Model, tea.Cmd) { return m, nil } +func readFilePrefix(path string, limit int) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, int64(limit))) +} + func (m *textModel) View() tea.View { if m.width == 0 || m.height == 0 { return tea.NewView("initializing…") } bodyH := m.bodyHeight() suffix := fmt.Sprintf(" (%d lines, %d paths)", len(m.lines), len(m.picks)) - header := m.theme.viewTitleLine(truncateMiddle(m.path, max(1, m.width-lipgloss.Width(suffix)))+suffix, m.width) + header := m.theme.viewTitleLine(layout.TruncateMiddle(m.path, max(1, m.width-lipgloss.Width(suffix)))+suffix, m.width) var b strings.Builder for i := m.top; i < len(m.lines) && i < m.top+bodyH; i++ { b.WriteString(m.renderLine(i)) b.WriteByte('\n') } - body := padBody(b.String(), m.width, bodyH) + body := layout.PadBody(b.String(), m.width, bodyH) footer := m.theme.footerStyle.Render("↑/↓ scroll · Enter/o open path menu · Esc back · q quit") if m.status != "" { footer = m.theme.infoStyle.Render(m.status) } - out := header + "\n" + body + "\n" + padRight(footer, m.width) + out := header + "\n" + body + "\n" + layout.PadRight(footer, m.width) if m.pickerActive { out = m.overlayCenterText(out, m.renderPicker()) @@ -289,7 +300,7 @@ func (m *textModel) renderLine(i int) string { line = m.hlLines[i] } line = underlineRanges(line, m.spans[i]) - return padRight(fitANSIWidth(line, m.width), m.width) + return layout.PadRight(layout.FitANSIWidth(line, m.width), m.width) } // underlineRanges adds an underline over the given byte ranges of an @@ -351,7 +362,7 @@ func (m *textModel) renderPicker() string { var sb strings.Builder sb.WriteString(m.theme.modalTitle("Open path")) sb.WriteString("\n\n") - top := visualTop(m.pickerSel, m.pickerTop, len(m.picks), visible, func(int) int { return 1 }) + top := layout.VisualTop(m.pickerSel, m.pickerTop, len(m.picks), visible, func(int) int { return 1 }) m.pickerTop = top end := min(top+visible, len(m.picks)) for i := top; i < end; i++ { @@ -359,7 +370,7 @@ func (m *textModel) renderPicker() string { if rel, err := filepath.Rel(m.dir, label); err == nil && !strings.HasPrefix(rel, "..") { label = rel } - line := padRight(" "+truncateMiddle(label, rowW-2), rowW) + line := layout.PadRight(" "+layout.TruncateMiddle(label, rowW-2), rowW) if i == m.pickerSel { line = m.theme.tableSelStyle.Render(line) } @@ -377,7 +388,7 @@ func (m *textModel) renderPicker() string { func (m *textModel) overlayCenterText(bg, modal string) string { mw := lipgloss.Width(modal) mh := lipgloss.Height(modal) - return overlay(bg, modal, (m.width-mw)/2, (m.height-mh)/2) + return layout.Overlay(bg, modal, (m.width-mw)/2, (m.height-mh)/2) } // extractPaths finds, per line, the byte spans that are filesystem paths @@ -386,7 +397,8 @@ func (m *textModel) overlayCenterText(bg, modal string) string { func extractPaths(lines []string, dir string) ([][]pathSpan, []string) { spans := make([][]pathSpan, len(lines)) seen := map[string]bool{} - cmdCache := map[string]string{} // memoised $PATH lookups + pathCache := map[string]string{} // memoised filesystem hits and misses + cmdCache := map[string]string{} // memoised $PATH lookups var picks []string for i, line := range lines { var ls []pathSpan @@ -401,7 +413,11 @@ func extractPaths(lines []string, dir string) ([][]pathSpan, []string) { } tok := line[s:e] // A filesystem path first; otherwise a bare command name on $PATH. - resolved := resolveExistingPath(tok, dir) + resolved, cached := pathCache[tok] + if !cached { + resolved = resolveExistingPath(tok, dir) + pathCache[tok] = resolved + } if resolved == "" { resolved = resolveCommand(tok, cmdCache) } diff --git a/internal/ui/textview_test.go b/internal/ui/textview_test.go index 18591c6..b09c79e 100644 --- a/internal/ui/textview_test.go +++ b/internal/ui/textview_test.go @@ -22,6 +22,20 @@ func TestLooksLikeText(t *testing.T) { } } +func TestReadFilePrefixStopsAtLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "large.txt") + if err := os.WriteFile(path, []byte(strings.Repeat("x", 1024)), 0o644); err != nil { + t.Fatal(err) + } + got, err := readFilePrefix(path, 17) + if err != nil { + t.Fatal(err) + } + if len(got) != 17 { + t.Fatalf("read %d bytes, want 17", len(got)) + } +} + func TestExtractPathsExistingOnly(t *testing.T) { dir := t.TempDir() // A real relative file the script references, plus a real absolute one. diff --git a/internal/ui/theme_bundled_test.go b/internal/ui/theme_bundled_test.go new file mode 100644 index 0000000..644499b --- /dev/null +++ b/internal/ui/theme_bundled_test.go @@ -0,0 +1,44 @@ +//go:build !lite + +package ui + +import ( + "testing" + + "github.com/rabarbra/exex/internal/chromastyles" + settingsmodal "github.com/rabarbra/exex/internal/ui/modals/settings" +) + +// TestSettingsThemesAreAllBundled is the guard for the two-registry bug: the +// picker used to enumerate every Chroma palette (75 entries) while only 19 style +// XMLs were embedded, so 55 themes silently dropped the source and disassembly +// panes to the minimal highlighter while the UI chrome themed correctly. +// +// Both lists now come from internal/chromasubset/styles.txt. If they ever drift +// again, this fails instead of shipping a picker that lies. +func TestSettingsThemesAreAllBundled(t *testing.T) { + for _, name := range settingsmodal.ThemeList(defaultThemeName) { + syntaxTheme := presetColors(name).SyntaxTheme + if syntaxTheme == "" { + syntaxTheme = darkSyntaxTheme // the "dark" built-in carries no overlay + } + if _, ok := chromastyles.Lookup(syntaxTheme); !ok { + t.Errorf("theme %q resolves to syntax style %q, which is not bundled", name, syntaxTheme) + } + } +} + +// TestPresetColorsNormalisesName pins the case/space handling: theme.PaletteFor +// is case-sensitive, so presetColors must fold before looking a Chroma style up. +func TestPresetColorsNormalisesName(t *testing.T) { + want := presetColors("dracula") + if want.SyntaxTheme != "dracula" { + t.Fatalf("baseline lookup failed: SyntaxTheme = %q", want.SyntaxTheme) + } + for _, name := range []string{"Dracula", "DRACULA", " dracula "} { + if got := presetColors(name); got.SyntaxTheme != want.SyntaxTheme || got.TitleBG != want.TitleBG { + t.Errorf("presetColors(%q) = {%q, %q}, want {%q, %q}", + name, got.SyntaxTheme, got.TitleBG, want.SyntaxTheme, want.TitleBG) + } + } +} diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index d6408b7..746944a 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -9,6 +9,7 @@ import ( "github.com/rabarbra/exex/internal/config" "github.com/rabarbra/exex/internal/theme" + "github.com/rabarbra/exex/internal/ui/layout" ) func TestPresetColorsLookup(t *testing.T) { @@ -213,7 +214,7 @@ func TestRenderBackgroundReappliesAfterANSIReset(t *testing.T) { if got := ansi.StringWidth(out); got != 4 { t.Fatalf("background row width = %d, want 4", got) } - if prefix := stylePrefix(bg); strings.Count(out, prefix) < 2 { + if prefix := layout.StylePrefix(bg); strings.Count(out, prefix) < 2 { t.Fatalf("background was not reapplied after inner reset: %q", out) } } @@ -226,13 +227,13 @@ func TestModalOverlayUsesThemedForegroundAndBackground(t *testing.T) { TableRowFG: "#040506", }}), } - out := m.overlayCenter(padBody("", m.width, m.height), m.theme.modalStyle.Render("popup text")) + out := m.overlayCenter(layout.PadBody("", m.width, m.height), m.theme.modalStyle.Render("popup text")) - bgPrefix := stylePrefix(lipgloss.NewStyle().Background(lipgloss.Color("#010203"))) + bgPrefix := layout.StylePrefix(lipgloss.NewStyle().Background(lipgloss.Color("#010203"))) if !strings.Contains(out, bgPrefix) { t.Fatalf("modal overlay missing configured background: %q", out) } - fgPrefix := stylePrefix(lipgloss.NewStyle().Foreground(lipgloss.Color("#040506"))) + fgPrefix := layout.StylePrefix(lipgloss.NewStyle().Foreground(lipgloss.Color("#040506"))) if !strings.Contains(out, fgPrefix) { t.Fatalf("modal overlay missing themed foreground: %q", out) } diff --git a/internal/ui/themes.go b/internal/ui/themes.go index 7ce0e95..0ca3b28 100644 --- a/internal/ui/themes.go +++ b/internal/ui/themes.go @@ -56,7 +56,8 @@ const ( nord15 = "#b48ead" // purple ) -const defaultThemeName = "nord" +// defaultThemeName is the theme applied when the config names none. +const defaultThemeName = theme.DefaultName func effectiveThemeName(name string) string { name = strings.ToLower(strings.TrimSpace(name)) @@ -67,12 +68,17 @@ func effectiveThemeName(name string) string { } // presetColors returns the colour overlay for a named theme. The three built-in -// names use hand-tuned palettes; any other name is matched against Chroma's -// style set (74 of them — dracula, monokai, github-dark, …) and derived into a -// full UI palette. An empty name is resolved before this function; unknown names -// keep the built-in dark defaults. +// names use hand-tuned palettes; any other name is matched against the curated +// Chroma style set (dracula, monokai, github-dark, …) and derived into a full UI +// palette. An empty name is resolved before this function; unknown names keep +// the built-in dark defaults. +// +// The name is lowercased and trimmed once up front: theme.PaletteFor is +// case-sensitive, so passing a raw config value straight to it would silently +// drop `theme: Dracula` back to the defaults. func presetColors(name string) config.Colors { - switch strings.ToLower(strings.TrimSpace(name)) { + name = strings.ToLower(strings.TrimSpace(name)) + switch name { case "", "dark": return config.Colors{} case "solarized-dark": @@ -92,8 +98,8 @@ func presetColors(name string) config.Colors { c.ViewBG = nord0 return c } - if p, ok := theme.PaletteFor(strings.TrimSpace(name)); ok { - return deriveColors(strings.TrimSpace(name), p) + if p, ok := theme.PaletteFor(name); ok { + return deriveColors(name, p) } return config.Colors{} } diff --git a/internal/ui/update.go b/internal/ui/update.go index 2a3eeb4..61f5faf 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -15,17 +15,17 @@ func (m *Model) Init() tea.Cmd { // immediately; names switch from raw to demangled when it completes. Skipped // when the user disabled demangling (the pass allocates 1+ GB on large C++/ // Swift binaries) — toggling it on later computes lazily. - if len(m.file.Symbols) > 0 && !m.cfg.Behavior.NoDemangle { + if len(m.file.Symbols) > 0 && len(m.demangledNames) == 0 && !m.cfg.Behavior.NoDemangle { cmds = append(cmds, m.demangleCmd()) } // If the configured default view is Disasm, switchMode already flagged a // decode; kick it off here (New can't return a Cmd). - if m.disasmDecoding && !m.disasmBuilt && m.dis != nil { - cmds = append(cmds, m.decodeDisasmCmd(m.disasmPendingAddr)) + if m.dasm.Decoding && !m.dasm.Built && m.dis != nil { + cmds = append(cmds, m.decodeDisasmCmd(m.dasm.PendingAddr)) } // Pre-warm the initial disasm window right after the first frame. This keeps // startup responsive while making the view ready for the common next action. - cmds = append(cmds, func() tea.Msg { return prewarmMsg{} }) + cmds = append(cmds, m.backgroundCmd(func() tea.Msg { return prewarmMsg{} })) return tea.Batch(cmds...) } @@ -39,9 +39,9 @@ type prewarmMsg struct{} // remains on-demand because it is large and only source-aware views need it. func (m *Model) handlePrewarm() (tea.Model, tea.Cmd) { var cmds []tea.Cmd - if m.dis != nil && !m.disasmBuilt && !m.disasmDecoding { - m.disasmDecoding = true - m.disasmPendingAddr = m.disasmInitAddr + if m.dis != nil && !m.dasm.Built && !m.dasm.Decoding { + m.dasm.Decoding = true + m.dasm.PendingAddr = m.disasmInitAddr cmds = append(cmds, m.decodeDisasmCmd(m.disasmInitAddr)) } return m, tea.Batch(cmds...) @@ -53,6 +53,9 @@ func (m *Model) setStatus(s string, isError bool) { } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if done, ok := msg.(backgroundDoneMsg); ok { + return m.handleBackgroundDone(done) + } // Assume each message changes the screen; the rare no-op paths (coalesced // wheel events) clear this so View() can reuse the previous frame. m.viewDirty = true @@ -94,6 +97,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case syscallFullDoneMsg: return m.handleSyscallFullDone(msg) + case findPartialMsg: + return m.handleFindPartial(msg) + case disasmPrefetchMsg: return m, nil @@ -126,37 +132,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) forwardToFocusedInput(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch { - case m.gotoActive: - before := m.gotoInput.Value() - m.gotoInput, cmd = m.gotoInput.Update(msg) - if m.gotoInput.Value() != before { - m.recomputeGoto() - } - case m.searchActive: - m.searchInput, cmd = m.searchInput.Update(msg) - case m.symbolsFilter.Focused(): - before := m.symbolsFilter.Value() - m.symbolsFilter, cmd = m.symbolsFilter.Update(msg) - if m.symbolsFilter.Value() != before { - m.recomputeSymbols() + case m.palette.Active(): + cmd = m.palette.HandleInput(m, msg) + case m.search.Active(): + cmd = m.search.HandleInput(msg) + case m.symbols.Filter.Focused(): + before := m.symbols.Filter.Value() + m.symbols.Filter, cmd = m.symbols.Filter.Update(msg) + if m.symbols.Filter.Value() != before { + m.symbols.Recompute(m.viewContext()) } - case m.sectionsFilter.Focused(): - before := m.sectionsFilter.Value() - m.sectionsFilter, cmd = m.sectionsFilter.Update(msg) - if m.sectionsFilter.Value() != before { - m.recomputeSections() + case m.sections.Filter.Focused(): + before := m.sections.Filter.Value() + m.sections.Filter, cmd = m.sections.Filter.Update(msg) + if m.sections.Filter.Value() != before { + m.sections.Recompute() } - case m.stringsFilter.Focused(): - before := m.stringsFilter.Value() - m.stringsFilter, cmd = m.stringsFilter.Update(msg) - if m.stringsFilter.Value() != before { - m.recomputeStrings() + case m.strs.Filter.Focused(): + before := m.strs.Filter.Value() + m.strs.Filter, cmd = m.strs.Filter.Update(msg) + if m.strs.Filter.Value() != before { + m.strs.Recompute(m.viewContext()) } - case m.sourcesFilter.Focused(): - before := m.sourcesFilter.Value() - m.sourcesFilter, cmd = m.sourcesFilter.Update(msg) - if m.sourcesFilter.Value() != before { - m.recomputeSourceFiles() + case m.sources.Filter.Focused(): + before := m.sources.Filter.Value() + m.sources.Filter, cmd = m.sources.Filter.Update(msg) + if m.sources.Filter.Value() != before { + m.sources.Recompute(m.viewContext()) } } return m, cmd @@ -167,37 +169,31 @@ func (m *Model) resize(width, height int) { m.clearAllViewCaches() } m.width, m.height = width, height - bodyH := m.bodyHeight() - m.headerVP.SetWidth(m.width) - m.headerVP.SetHeight(bodyH) - m.srcVP.SetWidth(m.width / 2) - m.srcVP.SetHeight(bodyH) } func (m *Model) handleDisasmReady(msg disasmReadyMsg) (tea.Model, tea.Cmd) { // Ignore late delivery if a synchronous jump already loaded a newer span. - if (msg.file != nil && msg.file != m.file) || !m.disasmDecoding || msg.addr != m.disasmPendingAddr { + if (msg.file != nil && msg.file != m.file) || !m.dasm.Decoding || msg.addr != m.dasm.PendingAddr { return m, nil } - m.disasmInst = msg.insts - m.disasmPosLo = m.posLoFor(msg.posLo, msg.insts) - m.disasmPosHi = msg.posHi - m.sourceAsmRowCache = nil - m.disasmHeightCache = nil - m.disasmBuilt = true - m.disasmDecoding = false - m.disasmPendingAddr = 0 + m.dasm.Inst = msg.span.Insts + m.dasm.PosLo, m.dasm.PosHi = msg.span.PosLo, msg.span.PosHi + m.dasm.SourceAsmRowCache = nil + m.dasm.HeightCache = nil + m.dasm.Built = true + m.dasm.Decoding = false + m.dasm.PendingAddr = 0 // A prewarm decode (the user isn't in the disasm view yet) only stores the // window — it must not switch the view or post a status. Positioning happens // when the user actually opens disasm (switchMode sees disasmBuilt). if m.mode != modeDisasm { return m, nil } - if len(m.disasmInst) == 0 { + if len(m.dasm.Inst) == 0 { m.setStatus("no executable code to disassemble", true) return m, nil } - if !m.disasmPositioned && m.disasmInitAddr != 0 { + if !m.dasm.Positioned && m.disasmInitAddr != 0 { m.loadDisasmAt(m.disasmInitAddr) } return m, nil @@ -214,21 +210,24 @@ func (m *Model) handleDisasmSearchProgress(msg disasmSearchProgressMsg) (tea.Mod m.searchCancelable = false m.searchCancel = nil if msg.hit != nil { - m.setDisasmWindow(msg.hit.win, msg.hit.insts) - m.disasmCur = msg.hit.idx - m.disasmTop = msg.hit.idx - m.disasmPositioned = true + if len(msg.hit.insts) > 0 { + m.dasm.SetSpan(m.disasmService().SpanFor(msg.hit.win, msg.hit.insts)) + m.dasm.Cur = msg.hit.idx + m.dasm.Top = msg.hit.idx + } else { + m.loadDisasmAt(msg.hit.addr) + } + m.dasm.Positioned = true m.setMode(modeDisasm) m.searchCursorMode = searchCursorAtMatch m.searchCursorAddr = msg.hit.addr m.setStatus("match: "+strings.TrimSpace(msg.hit.text), false) return m, m.prefetchDisasmAroundCmd(msg.hit.addr) } + m.searchResults.SetExhausted(msg.next.forward) if msg.next.forward { - m.searchResults.forwardExhausted = true m.searchCursorMode = searchCursorAfterEnd } else { - m.searchResults.backwardExhausted = true m.searchCursorMode = searchCursorBeforeStart } m.searchCursorAddr = 0 @@ -236,7 +235,7 @@ func (m *Model) handleDisasmSearchProgress(msg disasmSearchProgressMsg) (tea.Mod return m, nil } m.setStatus(msg.status, false) - return m, m.searchDisasmStepCmd(msg.next) + return m, m.backgroundCmd(m.searchDisasmStepCmd(msg.next)) } // demangleDoneMsg carries the result of the background symbol demangle. @@ -258,31 +257,21 @@ func (m *Model) applyDemangledNames(names []string) { // stale; they also appear in the disasm ":" labels and disasm/hex // annotations, whose cached row heights wrap by name length. func (m *Model) invalidateSymbolNameState() { - m.symbolsByDisplay = nil - m.symbolsTreeInit = false - m.symbolsCollapsed = nil - if m.symbolsReady { - m.recomputeSymbols() - } + m.symbols.OnNamesChanged(m.viewContext()) m.clearSymbolNameCaches() m.refreshModalSymbolNames() } func (m *Model) refreshModalSymbolNames() { m.xrefCache = nil - if m.xrefActive { + if m.xref.Active() { m.xrefLabel = m.xrefLabelForTarget(m.xrefTarget) - for i := range m.xrefResults { - m.xrefResults[i].sym = m.symbolDisplayAt(m.xrefResults[i].addr) - } - m.rebuildXrefRows() + m.xref.SetLabel(m.xrefLabel) + m.xref.RelabelSymbols(m.symbolDisplayAt) } m.syscallCached = nil - if m.syscallActive { - for i := range m.syscallResults { - m.syscallResults[i].Sym = m.symbolDisplayAt(m.syscallResults[i].Addr) - } - m.rebuildSyscallRows() + if m.syscalls.Active() { + m.syscalls.RelabelSymbols(m.symbolDisplayAt) } } @@ -315,7 +304,7 @@ func (m *Model) toggleDemangle() { // shows up immediately (with raw names) instead of blocking on startup. func (m *Model) demangleCmd() tea.Cmd { f := m.file - return func() tea.Msg { return demangleDoneMsg{file: f, names: f.ComputeDemangled()} } + return m.backgroundCmd(func() tea.Msg { return demangleDoneMsg{file: f, names: f.ComputeDemangled()} }) } // copyToClipboard puts text on the system clipboard and reports success or diff --git a/internal/ui/view/status.go b/internal/ui/view/status.go new file mode 100644 index 0000000..6f2bf37 --- /dev/null +++ b/internal/ui/view/status.go @@ -0,0 +1,171 @@ +package view + +// The status line: the row above a table showing the filter, how many rows are +// shown, and the view's toggles. Every view builds it through StatusLine so the +// grammar, colours, spacing and order are the same everywhere: +// +// / ( / )