From fb3fb958b7b14ecd673f3787c1d8ad136d0693b0 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Wed, 2 Sep 2026 14:38:42 +0200 Subject: [PATCH] search: allow search of symbols w/o source code --- src/common/searchtypes.ts | 3 +- src/ide/search/debuggerhit.ts | 78 +++++++++++++++ src/ide/search/searchview.ts | 29 +++++- src/ide/search/symbolindex.ts | 1 + src/ide/ui.ts | 20 +--- test/unit/testsearchdebuggerhit.ts | 149 +++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 src/ide/search/debuggerhit.ts create mode 100644 test/unit/testsearchdebuggerhit.ts diff --git a/src/common/searchtypes.ts b/src/common/searchtypes.ts index 780bf566..cda4f7e2 100644 --- a/src/common/searchtypes.ts +++ b/src/common/searchtypes.ts @@ -12,7 +12,7 @@ export type SymbolKind = | 'doc' | 'text'; -export type SymbolSourceKind = 'project' | 'toolchain' | 'docs'; +export type SymbolSourceKind = 'project' | 'toolchain' | 'docs' | 'debugger'; export interface SymbolRecord { id: string; // stable, unique within a corpus: \`${file}:${line}:${name}\` @@ -26,6 +26,7 @@ export interface SymbolRecord { file: string; // project path | '/include/joystick.h' | page URL line?: number; // 1-based url?: string; // docs only + addr?: number; // debugger only: runtime address of the symbol } /** On-disk shape of a prebuilt index (gen/symidx/.json). */ diff --git a/src/ide/search/debuggerhit.ts b/src/ide/search/debuggerhit.ts new file mode 100644 index 00000000..c88451a6 --- /dev/null +++ b/src/ide/search/debuggerhit.ts @@ -0,0 +1,78 @@ +/** + * DebuggerHit - resolves search queries with no source hits as runtime + * symbols / hex addresses, and builds synthetic search hits that jump to + * them in the Disassembly (code segments) or Memory Browser (everything else). + * + * Kept free of DOM / ui.ts dependencies so it can be unit tested. + */ + +import { SymbolRecord } from "../../common/searchtypes"; +import { Segment } from "../../common/workertypes"; +import { SearchHit } from "./types"; + +export type SymbolMap = { [sym: string]: number }; + +export interface DebuggerTarget { + addr: number; + isSymbol: boolean; // resolved from the symbol map (vs. raw hex input) +} + +/** + * Resolve a needle as a runtime symbol (exact match first, then + * case-insensitive) or a hex address ("$1234", "0x1234", "1234" -- bare + * digits are parsed as hex, like the listings). + * Returns null if it's neither. + */ +export function resolveDebuggerTarget(needle: string, symbolmap?: SymbolMap): DebuggerTarget | null { + needle = needle.trim(); + if (!needle) return null; + if (symbolmap) { + if (needle in symbolmap) return { addr: symbolmap[needle], isSymbol: true }; + const ls = needle.toLowerCase(); + for (var sym in symbolmap) { + if (sym.toLowerCase() === ls) return { addr: symbolmap[sym], isSymbol: true }; + } + } + if (/^(0x|\$)[0-9a-f]+$/i.test(needle)) return { addr: parseInt(needle.replace(/^(0x|\$)/i, ''), 16), isSymbol: false }; + if (/^[0-9a-f]+$/i.test(needle)) return { addr: parseInt(needle, 16), isSymbol: false }; + return null; +} + +/** Find the segment containing an address, if the memory map has one. */ +export function findSegmentAt(addr: number, segments?: Segment[]): Segment | null { + for (const seg of segments || []) { + const end = seg.last != null ? seg.last : seg.start + seg.size - 1; + if (addr >= seg.start && addr <= end) return seg; + } + return null; +} + +/** + * Build the synthetic search hit for a resolved symbol/address. + * 'rom' segments are treated as code (open in the Disassembler, kind=label); + * anything else as data (open in the Memory Browser, kind=var). + */ +export function makeDebuggerHit(needle: string, target: DebuggerTarget, segments?: Segment[]): SearchHit { + const seg = findSegmentAt(target.addr, segments); + const isCode = seg?.type === 'rom'; + const rec: SymbolRecord = { + id: 'debugger:' + needle.toLowerCase(), + name: needle, + kind: isCode ? 'label' : 'var', + brief: isCode ? 'open in Disassembly' : 'open in Memory Browser', + source: 'debugger', + file: '', + addr: target.addr, + }; + return { record: rec, score: 500 }; +} + +/** + * Whether to offer the debugger hit alongside regular search results: + * exact symbol matches are always offered (below source hits); + * raw hex addresses are only offered when there are no other hits + * (a query like "add" is valid hex and shouldn't add noise). + */ +export function shouldOfferDebuggerHit(target: DebuggerTarget, hasOtherHits: boolean): boolean { + return target.isSymbol || !hasOtherHits; +} diff --git a/src/ide/search/searchview.ts b/src/ide/search/searchview.ts index 9fe34284..6f70446d 100644 --- a/src/ide/search/searchview.ts +++ b/src/ide/search/searchview.ts @@ -6,8 +6,11 @@ import { searchService } from "./service"; import { SearchHit } from "./types"; -import { projectWindows, openHeaderFile } from "../ui"; -import { SourceEditor, HeaderView } from "../views/editors"; +import { hex } from "../../common/util"; +import { projectWindows, openHeaderFile, current_project, platform } from "../ui"; +import { SourceEditor, HeaderView, DisassemblerView } from "../views/editors"; +import { MemoryView } from "../views/debugviews"; +import { resolveDebuggerTarget, makeDebuggerHit, shouldOfferDebuggerHit } from "./debuggerhit"; declare var $: JQueryStatic; declare var bootbox: any; @@ -51,6 +54,18 @@ export function openSearchHit(hit: SearchHit) { if (wnd instanceof HeaderView && rec.line) { wnd.navigateToLine(rec.line); } + } else if (rec.source === 'debugger') { + // Runtime symbol/address with no source: jump to it in the + // disassembler (code segments) or memory browser (everything else) + const addr = rec.addr ?? -1; + if (addr < 0) return; + if (rec.kind === 'label' && projectWindows.isWindow('#disasm')) { + const wnd = projectWindows.createOrShow('#disasm'); + if (wnd instanceof DisassemblerView) wnd.goToAddress(addr); + } else { + const wnd = projectWindows.createOrShow('#memory'); + if (wnd instanceof MemoryView) wnd.goToAddress(addr); + } } else if (rec.source === 'docs') { // External docs: open the URL in a new tab const url = rec.detail || rec.file || ''; @@ -148,7 +163,8 @@ export function openSearchDialog() { rec.kind === 'struct' ? 'S' : rec.kind === 'enum' ? 'E' : rec.kind === 'text' ? '≡' : '•'; - const loc = rec.file && rec.line ? `${rec.file}:${rec.line}` : (rec.file || ''); + const loc = rec.source === 'debugger' ? hex(rec.addr, 4) + : rec.file && rec.line ? `${rec.file}:${rec.line}` : (rec.file || ''); const isSmart = rec.kind !== 'text'; const li = $('
  • '); li.html( @@ -179,6 +195,13 @@ export function openSearchDialog() { } try { const hits = await searchService.query(needle, MAX_RESULTS); + // offer a jump to the emulator views for runtime symbols/addresses; + // exact symbol matches always appear (below source hits), raw hex + // addresses only when there are no source hits + const target = resolveDebuggerTarget(needle, platform && platform.debugSymbols && platform.debugSymbols.symbolmap); + if (target && shouldOfferDebuggerHit(target, hits.length > 0)) { + hits.push(makeDebuggerHit(needle, target, current_project && current_project.segments)); + } render(hits); } catch (e) { resultsDiv.html('
    Search failed.
    '); diff --git a/src/ide/search/symbolindex.ts b/src/ide/search/symbolindex.ts index f38c7feb..81d44a76 100644 --- a/src/ide/search/symbolindex.ts +++ b/src/ide/search/symbolindex.ts @@ -10,6 +10,7 @@ const SOURCE_WEIGHTS: Record = { project: 1.3, toolchain: 1.0, docs: 0.7, + debugger: 0.9, // runtime symbols (never indexed here; weight unused) }; // Declarations (real code symbols) rank above docs; text hits rank lowest. diff --git a/src/ide/ui.ts b/src/ide/ui.ts index 36f05b40..520c0064 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -23,6 +23,7 @@ import { _importProjectFromGithub, _loginToGithub, _logoutOfGithub, _publishProj import { Toolbar } from "./toolbar"; import { Shortcut, initShortcutBar, setGlobalShortcutsFn, setViewShortcutsFn, setBarVisible, refreshShortcutBar } from "./shortcutbar"; import { openSearchDialog } from "./search/searchview"; +import { resolveDebuggerTarget } from "./search/debuggerhit"; import { setProjectProvider } from "./search/projectsource"; import { AssetEditorView } from "./views/asseteditor"; import { isMobileDevice } from "./views/baseviews"; @@ -1106,22 +1107,9 @@ function getGoToAddressView(): DisassemblerView | MemoryView | null { return null; } -function parseGoToTarget(s: string): number { - s = s.trim(); - if (!s) return -1; - // symbol name first (so symbols like "add" aren't parsed as hex) - var symmap = platform.debugSymbols && platform.debugSymbols.symbolmap; - if (symmap && s in symmap) return symmap[s]; - var ls = s.toLowerCase(); - if (symmap) { - for (var sym in symmap) { - if (sym.toLowerCase() === ls) return symmap[sym]; - } - } - // hex address (bare digits are parsed as hex, like the listings) - if (/^(0x|\$)[0-9a-f]+$/i.test(s)) return parseInt(s.replace(/^(0x|\$)/i, ''), 16); - if (/^[0-9a-f]+$/i.test(s)) return parseInt(s, 16); - return -1; +export function parseGoToTarget(s: string): number { + var target = resolveDebuggerTarget(s, platform && platform.debugSymbols && platform.debugSymbols.symbolmap); + return target ? target.addr : -1; } function promptGoToAddress(): boolean { diff --git a/test/unit/testsearchdebuggerhit.ts b/test/unit/testsearchdebuggerhit.ts new file mode 100644 index 00000000..05ca59a0 --- /dev/null +++ b/test/unit/testsearchdebuggerhit.ts @@ -0,0 +1,149 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { + resolveDebuggerTarget, + findSegmentAt, + makeDebuggerHit, + shouldOfferDebuggerHit, +} from "../../src/ide/search/debuggerhit"; +import { Segment } from "../../src/common/workertypes"; + +const SYMBOLS = { + main: 0xc000, + JOY_READ: 0xf000, + videoTop: 0x0200, +}; + +describe('DebuggerHit', function () { + + describe('resolveDebuggerTarget', function () { + it('should resolve exact symbol names', function () { + const t = resolveDebuggerTarget('main', SYMBOLS); + assert.ok(t); + assert.strictEqual(t!.addr, 0xc000); + assert.strictEqual(t!.isSymbol, true); + }); + + it('should resolve symbol names case-insensitively', function () { + const t = resolveDebuggerTarget('joy_read', SYMBOLS); + assert.ok(t); + assert.strictEqual(t!.addr, 0xf000); + assert.strictEqual(t!.isSymbol, true); + }); + + it('should prefer symbol map over hex parse', function () { + // "add" is valid hex (0xadd) but if a symbol exists, it wins + const t = resolveDebuggerTarget('add', { add: 0x123 }); + assert.ok(t); + assert.strictEqual(t!.addr, 0x123); + assert.strictEqual(t!.isSymbol, true); + }); + + it('should parse $-prefixed hex', function () { + const t = resolveDebuggerTarget('$c000'); + assert.ok(t); + assert.strictEqual(t!.addr, 0xc000); + assert.strictEqual(t!.isSymbol, false); + }); + + it('should parse 0x-prefixed hex', function () { + const t = resolveDebuggerTarget('0xC000'); + assert.ok(t); + assert.strictEqual(t!.addr, 0xc000); + assert.strictEqual(t!.isSymbol, false); + }); + + it('should parse bare hex digits', function () { + const t = resolveDebuggerTarget('C000'); + assert.ok(t); + assert.strictEqual(t!.addr, 0xc000); + assert.strictEqual(t!.isSymbol, false); + }); + + it('should return null for non-hex identifiers', function () { + assert.strictEqual(resolveDebuggerTarget('zzz'), null); + assert.strictEqual(resolveDebuggerTarget('main loop'), null); + assert.strictEqual(resolveDebuggerTarget(''), null); + assert.strictEqual(resolveDebuggerTarget(' '), null); + }); + + it('should return null for hex-like names when no symbol map exists... or resolve as hex', function () { + // without a symbol map, hex-like needles still resolve (as raw hex) + const t = resolveDebuggerTarget('add'); + assert.ok(t); + assert.strictEqual(t!.addr, 0xadd); + assert.strictEqual(t!.isSymbol, false); + }); + }); + + describe('findSegmentAt', function () { + const segs: Segment[] = [ + { name: 'Code', start: 0xc000, size: 0x1000, type: 'rom' }, + { name: 'RAM', start: 0x0200, size: 0x100, type: 'ram' }, + { name: 'IO', start: 0x8000, last: 0x8003, size: 0x4, type: 'io' }, + ]; + + it('should find the segment containing an address', function () { + assert.strictEqual(findSegmentAt(0xc123, segs)?.name, 'Code'); + assert.strictEqual(findSegmentAt(0x02ff, segs)?.name, 'RAM'); + }); + + it('should respect the last field when present', function () { + assert.strictEqual(findSegmentAt(0x8003, segs)?.name, 'IO'); + assert.strictEqual(findSegmentAt(0x8004, segs), null); + }); + + it('should return null for unmapped addresses', function () { + assert.strictEqual(findSegmentAt(0x0000, segs), null); + assert.strictEqual(findSegmentAt(0x10000, segs), null); + }); + + it('should handle missing segment lists', function () { + assert.strictEqual(findSegmentAt(0xc000, undefined), null); + assert.strictEqual(findSegmentAt(0xc000, []), null); + }); + }); + + describe('makeDebuggerHit', function () { + it('should build a disassembler hit for code (rom) segments', function () { + const segs: Segment[] = [{ name: 'Code', start: 0xc000, size: 0x1000, type: 'rom' }]; + const hit = makeDebuggerHit('main', { addr: 0xc100, isSymbol: true }, segs); + assert.strictEqual(hit.record.source, 'debugger'); + assert.strictEqual(hit.record.kind, 'label'); + assert.strictEqual(hit.record.brief, 'open in Disassembly'); + assert.strictEqual(hit.record.addr, 0xc100); + assert.strictEqual(hit.record.name, 'main'); + }); + + it('should build a memory browser hit for data (ram) segments', function () { + const segs: Segment[] = [{ name: 'RAM', start: 0x0200, size: 0x100, type: 'ram' }]; + const hit = makeDebuggerHit('videoTop', { addr: 0x0200, isSymbol: true }, segs); + assert.strictEqual(hit.record.kind, 'var'); + assert.strictEqual(hit.record.brief, 'open in Memory Browser'); + assert.strictEqual(hit.record.addr, 0x0200); + }); + + it('should default to the memory browser when no segment matches', function () { + const hit = makeDebuggerHit('$9000', { addr: 0x9000, isSymbol: false }, []); + assert.strictEqual(hit.record.kind, 'var'); + assert.strictEqual(hit.record.brief, 'open in Memory Browser'); + }); + + it('should give debugger hits a low score so source hits rank first', function () { + const hit = makeDebuggerHit('main', { addr: 0xc000, isSymbol: true }, []); + assert.ok(hit.score < 1000); + }); + }); + + describe('shouldOfferDebuggerHit', function () { + it('should always offer exact symbol matches', function () { + assert.strictEqual(shouldOfferDebuggerHit({ addr: 0xc000, isSymbol: true }, true), true); + assert.strictEqual(shouldOfferDebuggerHit({ addr: 0xc000, isSymbol: true }, false), true); + }); + + it('should only offer raw hex when there are no other hits', function () { + assert.strictEqual(shouldOfferDebuggerHit({ addr: 0xadd, isSymbol: false }, false), true); + assert.strictEqual(shouldOfferDebuggerHit({ addr: 0xadd, isSymbol: false }, true), false); + }); + }); +});