search: allow search of symbols w/o source code

This commit is contained in:
Steven Hugg committed 2026-09-02 14:54:02 +02:00
1 parent 242cb71fcf
commit fb3fb958b7
6 files changed
+260 -20

No files matched your search

+2 -1
View File
@@ -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/<name>.json). */
+78
View File
@@ -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;
}
+26 -3
View File
@@ -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 class="list-group-item search-hit" style="cursor:pointer;padding:6px 10px;display:flex;align-items:center;overflow:hidden"></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('<div class="text-danger" style="padding:8px">Search failed.</div>');
+1
View File
@@ -10,6 +10,7 @@ const SOURCE_WEIGHTS: Record<SymbolSourceKind, number> = {
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.
+4 -16
View File
@@ -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 {
+149
View File
@@ -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);
});
});
});