mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 00:02:16 +00:00
started on search feature
This commit is contained in:
1 parent
2f39beeb38
commit
b749c369a7
25 files changed
+1700
-61
No files matched your search
Generated
+15
-1
@@ -13,6 +13,7 @@
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/view": "^6.39.14",
|
||||
"@leeoniya/ufuzzy": "^1.0.19",
|
||||
"@lezer/lr": "^1.4.8",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/emscripten": "^1.39.5",
|
||||
@@ -26,12 +27,13 @@
|
||||
"jquery": "^3.6.3",
|
||||
"jszip": "^3.7.0",
|
||||
"localforage": "^1.9.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"octokat": "^0.10.0",
|
||||
"split.js": "^1.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"8bws": "gen/cli/8bws.js"
|
||||
"8bws": "gen/tools/8bws.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.8.0",
|
||||
@@ -1078,6 +1080,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@leeoniya/ufuzzy": {
|
||||
"version": "1.0.19",
|
||||
"resolved": "https://registry.npmjs.org/@leeoniya/ufuzzy/-/ufuzzy-1.0.19.tgz",
|
||||
"integrity": "sha512-0pikDeYt0IHEUPza5RTCDXc/17S1pTrYnReEMp8Aa6k1ovzw5QdZLwicW8TjljwEZRb6oYag0xmALohrcq/yOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
@@ -6007,6 +6015,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/minisearch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
|
||||
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/view": "^6.39.14",
|
||||
"@leeoniya/ufuzzy": "^1.0.19",
|
||||
"@lezer/lr": "^1.4.8",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/emscripten": "^1.39.5",
|
||||
@@ -30,6 +31,7 @@
|
||||
"jquery": "^3.6.3",
|
||||
"jszip": "^3.7.0",
|
||||
"localforage": "^1.9.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"octokat": "^0.10.0",
|
||||
"split.js": "^1.6.2"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export type SymbolKind =
|
||||
| 'func'
|
||||
| 'macro'
|
||||
| 'type'
|
||||
| 'struct'
|
||||
| 'enum'
|
||||
| 'var'
|
||||
| 'label'
|
||||
| 'equate'
|
||||
| 'proc'
|
||||
| 'module'
|
||||
| 'doc'
|
||||
| 'text';
|
||||
|
||||
export type SymbolSourceKind = 'project' | 'toolchain' | 'docs';
|
||||
|
||||
export interface SymbolRecord {
|
||||
id: string; // stable, unique within a corpus: \`${file}:${line}:${name}\`
|
||||
name: string; // the identifier, or page title for docs
|
||||
kind: SymbolKind;
|
||||
signature?: string; // rendered declaration, verbatim source text
|
||||
brief?: string; // first sentence of the doc comment
|
||||
detail?: string; // remainder of the doc comment
|
||||
section?: string; // enclosing banner-comment heading, e.g. "Declarations"
|
||||
source: SymbolSourceKind;
|
||||
file: string; // project path | '/include/joystick.h' | page URL
|
||||
line?: number; // 1-based
|
||||
url?: string; // docs only
|
||||
}
|
||||
|
||||
/** On-disk shape of a prebuilt index (gen/symidx/<name>.json). */
|
||||
export interface PrebuiltIndex {
|
||||
version: number; // bump when the extractor changes; runtime rejects mismatches
|
||||
corpus: string; // fs name, e.g. 'fs65-c64', or 'docs'
|
||||
generated: string; // ISO date
|
||||
records: SymbolRecord[];
|
||||
ms?: object; // MiniSearch.toJSON() output, loaded via loadJSAsync
|
||||
}
|
||||
|
||||
export const SYMIDX_VERSION = 1;
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Doc comment extraction shared across all tiers.
|
||||
* Extracts brief/detail/section from C-style comments.
|
||||
*/
|
||||
|
||||
export interface DocInfo {
|
||||
brief?: string;
|
||||
detail?: string;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
/** Check if a comment line is a banner (decoration) */
|
||||
function isBannerLine(line: string): boolean {
|
||||
const stripped = line.trim();
|
||||
if (stripped.length === 0) return false;
|
||||
const nonBanner = stripped.replace(/[*\-=\/#]/g, '');
|
||||
return nonBanner.length === 0 || nonBanner.length / stripped.length < 0.2;
|
||||
}
|
||||
|
||||
/** Extract section from a banner comment line */
|
||||
function extractBannerSection(line: string): string | null {
|
||||
let stripped = line.trim();
|
||||
stripped = stripped.replace(/^\s*\/\*\*?/,'').replace(/\*\/$/,'').replace(/^#\s?/,'').trim();
|
||||
if (/^[A-Z][A-Z0-9 _]*$/.test(stripped) && stripped.length <= 40) {
|
||||
return stripped;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract doc info from lines above position `lineNo` (1-based). */
|
||||
export function extractDocComment(
|
||||
lines: string[],
|
||||
lineNo: number
|
||||
): DocInfo | null {
|
||||
const docLines: string[] = [];
|
||||
let section: string | null = null;
|
||||
let i = lineNo - 2;
|
||||
|
||||
// Skip blank lines immediately above
|
||||
while (i >= 0 && lines[i].trim() === '') {
|
||||
i--;
|
||||
}
|
||||
if (i < 0) return null;
|
||||
|
||||
// Check for doc block (/** ... */)
|
||||
const docBlockRegex = /^\s*\/\*\*(.*)\*\/\s*$/;
|
||||
const docBlockMatch = lines[i].match(docBlockRegex);
|
||||
|
||||
if (docBlockMatch) {
|
||||
docLines.push(docBlockMatch[1].trim());
|
||||
i--;
|
||||
while (i >= 0) {
|
||||
const m = lines[i].match(docBlockRegex);
|
||||
if (m) {
|
||||
docLines.unshift(m[1].trim());
|
||||
i--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Collect plain comments above (no blank line between)
|
||||
let hasBlank = false;
|
||||
while (i >= 0 && !hasBlank) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') {
|
||||
hasBlank = true;
|
||||
break;
|
||||
}
|
||||
if (isBannerLine(line)) {
|
||||
const bannerSection = extractBannerSection(line);
|
||||
if (bannerSection) {
|
||||
section = bannerSection;
|
||||
}
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('//') || line.startsWith('/*')) {
|
||||
docLines.unshift(line);
|
||||
i--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (docLines.length === 0) return null;
|
||||
}
|
||||
|
||||
return parseDocLines(docLines, section);
|
||||
}
|
||||
|
||||
/** Parse collected comment lines into structured doc info */
|
||||
function parseDocLines(lines: string[], sectionOverride?: string | null): DocInfo | null {
|
||||
const cleaned: string[] = [];
|
||||
for (let line of lines) {
|
||||
// Strip leading //
|
||||
line = line.replace(/^\s*\/\/\s*/, '').trim();
|
||||
// Strip leading /* */ style
|
||||
line = line.replace(/^\s*\/\*/, '').replace(/\*\/\s*$/, '').trim();
|
||||
// Strip leading * (common in block comments)
|
||||
line = line.replace(/^\s*\*\s*/, '').trim();
|
||||
// Strip ;; decoration
|
||||
line = line.replace(/^;;\s/, '').trim();
|
||||
// Strip leading # decoration
|
||||
line = line.replace(/^#\s/, '').trim();
|
||||
// Collapse multiple spaces
|
||||
line = line.replace(/\s+/g, ' ').trim();
|
||||
if (line.length > 0) {
|
||||
cleaned.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned.length === 0) return null;
|
||||
|
||||
let fullText = cleaned.join(' ');
|
||||
let brief: string | undefined;
|
||||
let detail: string | undefined;
|
||||
|
||||
const periodSpaceIdx = fullText.indexOf('. ');
|
||||
if (periodSpaceIdx >= 0) {
|
||||
brief = fullText.substring(0, periodSpaceIdx + 1).trim();
|
||||
detail = fullText.substring(periodSpaceIdx + 2).trim();
|
||||
} else {
|
||||
brief = fullText;
|
||||
}
|
||||
|
||||
const result: DocInfo = {
|
||||
brief: brief && brief.length > 0 ? brief : undefined,
|
||||
detail: detail && detail.length > 0 ? detail : undefined,
|
||||
section: sectionOverride || undefined
|
||||
};
|
||||
|
||||
if (result.detail && result.brief && result.brief.endsWith('.')) {
|
||||
result.brief = result.brief.slice(0, -1);
|
||||
}
|
||||
|
||||
if (!result.brief && !result.detail) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Extract doc info from a trailing comment on the same line */
|
||||
export function extractTrailingComment(line: string): string | undefined {
|
||||
const trailingSlash = line.match(/\/\/\s*(.*)$/);
|
||||
if (trailingSlash && trailingSlash[1]) {
|
||||
return trailingSlash[1].trim();
|
||||
}
|
||||
const trailingBlock = line.match(/\/\*\s*(.*?)\*\/\s*$/);
|
||||
if (trailingBlock && trailingBlock[1]) {
|
||||
return trailingBlock[1].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export type { SymbolRecord, SymbolKind, SymbolSourceKind, PrebuiltIndex, SYMIDX_VERSION } from "../searchtypes";
|
||||
export { extractDocComment, extractTrailingComment, type DocInfo } from "./doccomment";
|
||||
|
||||
import { SymbolRecord, SymbolSourceKind } from "../searchtypes";
|
||||
import { extractDocComment, extractTrailingComment } from "./doccomment";
|
||||
|
||||
export function extractSymbols(
|
||||
text: string,
|
||||
filename: string,
|
||||
editorStyle: string,
|
||||
source: SymbolSourceKind
|
||||
): SymbolRecord[] {
|
||||
// Basic fallback extraction using regex patterns
|
||||
const records: SymbolRecord[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Simple patterns for common languages
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNo = i + 1;
|
||||
|
||||
// Function/method definitions
|
||||
const funcMatch = line.match(/(?:function|func|def|proc)\s+(\w+)/i);
|
||||
if (funcMatch) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${funcMatch[1]}`,
|
||||
name: funcMatch[1],
|
||||
kind: 'func',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
|
||||
// Variable/const declarations
|
||||
const varMatch = line.match(/(?:var|let|const|global)\s+(\w+)/i);
|
||||
if (varMatch) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${varMatch[1]}`,
|
||||
name: varMatch[1],
|
||||
kind: 'var',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
const typeMatch = line.match(/(?:struct|class|interface|type)\s+(\w+)/i);
|
||||
if (typeMatch) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${typeMatch[1]}`,
|
||||
name: typeMatch[1],
|
||||
kind: 'type',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
|
||||
// Labels (assembly)
|
||||
const labelMatch = line.match(/^\s*(\w+)\s*:/);
|
||||
if (labelMatch && !line.trim().startsWith('//')) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${labelMatch[1]}`,
|
||||
name: labelMatch[1],
|
||||
kind: 'label',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
|
||||
// Macros
|
||||
const macroMatch = line.match(/#define\s+(\w+)/i);
|
||||
if (macroMatch) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${macroMatch[1]}`,
|
||||
name: macroMatch[1],
|
||||
kind: 'macro',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
|
||||
// Equates
|
||||
const equateMatch = line.match(/^\s*(\w+)\s*(?:=|equ)\s+/i);
|
||||
if (equateMatch) {
|
||||
records.push({
|
||||
id: `${filename}:${lineNo}:${equateMatch[1]}`,
|
||||
name: equateMatch[1],
|
||||
kind: 'equate',
|
||||
source,
|
||||
file: filename,
|
||||
line: lineNo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SymbolIndex } from "./symbolindex";
|
||||
import { SymbolSourceKind, PrebuiltIndex, SYMIDX_VERSION } from "../../common/searchtypes";
|
||||
import { SearchHit, SearchSource } from "./types";
|
||||
|
||||
export class DocsSource implements SearchSource {
|
||||
id: string = 'docs';
|
||||
kind: SymbolSourceKind = 'docs';
|
||||
private index: SymbolIndex | null = null;
|
||||
private readyPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async ready() {
|
||||
if (this.readyPromise) return this.readyPromise;
|
||||
this.readyPromise = this.load();
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
try {
|
||||
const response = await fetch('https://8bitworkshop.com/docs/searchindex.json');
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.version !== SYMIDX_VERSION) {
|
||||
return;
|
||||
}
|
||||
const indexData = json as PrebuiltIndex;
|
||||
const idx = new SymbolIndex(indexData.records);
|
||||
await idx.init();
|
||||
if (indexData.ms) {
|
||||
await idx.setMiniSearch(indexData.ms);
|
||||
}
|
||||
this.index = idx;
|
||||
} catch (e) {
|
||||
console.debug('Docs search index unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
query(needle: string, limit: number): SearchHit[] {
|
||||
if (!this.index) return [];
|
||||
return this.index.query(needle, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { SymbolIndex } from "./symbolindex";
|
||||
import { SymbolRecord, SymbolSourceKind } from "../../common/searchtypes";
|
||||
import { SearchHit, SearchSource } from "./types";
|
||||
import { extractSymbols } from "../../common/symbols";
|
||||
|
||||
// Avoid a hard dependency on ../ui (which pulls in the whole IDE and its
|
||||
// language grammar imports that crash under plain Node). ui.ts registers a
|
||||
// getter here during startup instead.
|
||||
type ProjectProvider = () => { mainPath: string; iterateFiles(cb: (path: string, data: any) => void): void } | null;
|
||||
let projectProvider: ProjectProvider = () => null;
|
||||
|
||||
export function setProjectProvider(fn: ProjectProvider) {
|
||||
projectProvider = fn;
|
||||
}
|
||||
|
||||
function getProject() {
|
||||
return projectProvider();
|
||||
}
|
||||
|
||||
export class ProjectSource implements SearchSource {
|
||||
id: string = 'project';
|
||||
kind: SymbolSourceKind = 'project';
|
||||
private index: SymbolIndex;
|
||||
private rebuilt: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.index = new SymbolIndex([]);
|
||||
}
|
||||
|
||||
async ready() {
|
||||
await this.index.init();
|
||||
// Lazily index the current project files on first query.
|
||||
if (!this.rebuilt) {
|
||||
this.rebuilt = true;
|
||||
await this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
query(needle: string, limit: number): SearchHit[] {
|
||||
// Symbol/name matches first (high priority), then full-text line matches
|
||||
// at low priority so code text (not just identifiers) is searchable.
|
||||
const symbolHits = this.index.query(needle, limit);
|
||||
const textHits = this.fulltextQuery(needle, limit);
|
||||
|
||||
const hits = symbolHits.concat(textHits);
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
return hits.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-priority full-text search over the raw project source text.
|
||||
* Rather than a real index, we scan current_project.filedata directly
|
||||
* (project files are small and few); results rank below any symbol hit.
|
||||
*/
|
||||
private fulltextQuery(needle: string, limit: number): SearchHit[] {
|
||||
if (needle.length < 2) return [];
|
||||
|
||||
const needleLower = needle.toLowerCase();
|
||||
const hits: SearchHit[] = [];
|
||||
const MAX_PER_FILE = Math.max(limit, 10);
|
||||
|
||||
const project = getProject();
|
||||
if (!project) return [];
|
||||
|
||||
project.iterateFiles((path, data) => {
|
||||
if (typeof data !== 'string') return;
|
||||
const lines = data.split('\n');
|
||||
let fileCount = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineLower = line.toLowerCase();
|
||||
const col = lineLower.indexOf(needleLower);
|
||||
if (col >= 0) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
hits.push({
|
||||
record: {
|
||||
id: `text:${path}:${i + 1}`,
|
||||
name: trimmed.length > 80 ? trimmed.slice(0, 80) + '…' : trimmed,
|
||||
kind: 'text',
|
||||
source: 'project',
|
||||
file: path,
|
||||
line: i + 1,
|
||||
},
|
||||
// Well below symbol matches (BASE_MATCH_SCORE ~1000) but above 0.
|
||||
// Slight bonus for a match near the start of the line.
|
||||
score: 200 + Math.max(0, 100 - col * 2),
|
||||
ranges: [col, col + needle.length],
|
||||
});
|
||||
if (++fileCount >= MAX_PER_FILE) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
async onFileChanged(path: string, data: Uint8Array | string) {
|
||||
const records = this.extractSymbolsFromFile(path, data, 'project');
|
||||
this.index.pushRecords(records);
|
||||
}
|
||||
|
||||
private extractSymbolsFromFile(path: string, data: Uint8Array | string, source: SymbolSourceKind): SymbolRecord[] {
|
||||
if (typeof data !== 'string') return [];
|
||||
const editorStyle = this.detectEditorStyle(path);
|
||||
return extractSymbols(data, path, editorStyle, source);
|
||||
}
|
||||
|
||||
private detectEditorStyle(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'c':
|
||||
case 'h':
|
||||
return 'text/x-csrc';
|
||||
case 'asm':
|
||||
case 'a65':
|
||||
case 'inc':
|
||||
return '6502';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'wiz':
|
||||
return 'text/x-wiz';
|
||||
case 'bas':
|
||||
case 'basic':
|
||||
return 'basic';
|
||||
case 'fb':
|
||||
case 'fastbasic':
|
||||
return 'fastbasic';
|
||||
case 'bac':
|
||||
case 'bataribasic':
|
||||
return 'bataribasic';
|
||||
case 'i':
|
||||
return 'inform6';
|
||||
case 'd':
|
||||
return 'dialog';
|
||||
default:
|
||||
return 'text/x-csrc';
|
||||
}
|
||||
}
|
||||
|
||||
async rebuild() {
|
||||
this.index.clear();
|
||||
await this.index.init();
|
||||
|
||||
const project = getProject();
|
||||
if (!project) return;
|
||||
|
||||
const records: SymbolRecord[] = [];
|
||||
project.iterateFiles((path, data) => {
|
||||
if (typeof data !== 'string') return;
|
||||
const editorStyle = this.detectEditorStyle(path);
|
||||
const fileRecords = extractSymbols(data, path, editorStyle, 'project');
|
||||
records.push(...fileRecords);
|
||||
});
|
||||
|
||||
this.index.pushRecords(records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<div id="searchModal" class="modal fade" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Search</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search symbols, files, and documentation...">
|
||||
<ul id="search_results" class="list-group" style="margin-top: 10px;"></ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Search View - bootbox-based palette for searching project files,
|
||||
* toolchain headers, and docs. Opens an overlay dialog with a live
|
||||
* input; results are clickable and open the associated file/header.
|
||||
*/
|
||||
|
||||
import { searchService } from "./service";
|
||||
import { SearchHit } from "./types";
|
||||
import { projectWindows, openHeaderFile } from "../ui";
|
||||
import { SourceEditor, HeaderView } from "../views/editors";
|
||||
|
||||
declare var $: JQueryStatic;
|
||||
declare var bootbox: any;
|
||||
|
||||
/** Debounce helper */
|
||||
function debounce(fn: () => void, ms: number) {
|
||||
let timer: any = null;
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(fn, ms);
|
||||
};
|
||||
}
|
||||
|
||||
/** Max results shown in the palette */
|
||||
const MAX_RESULTS = 30;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the file/header/doc associated with a search hit. */
|
||||
export function openSearchHit(hit: SearchHit) {
|
||||
const rec = hit.record;
|
||||
|
||||
if (rec.source === 'project') {
|
||||
// Open a project file at the matching line
|
||||
const path = rec.file;
|
||||
if (!path || !projectWindows.isWindow(path)) return;
|
||||
const wnd = projectWindows.createOrShow(path);
|
||||
if (wnd instanceof SourceEditor && rec.line) {
|
||||
wnd.navigateToLine(rec.line);
|
||||
}
|
||||
} else if (rec.source === 'toolchain') {
|
||||
// Open a shared header file
|
||||
const fn = rec.file;
|
||||
if (!fn) return;
|
||||
openHeaderFile(fn);
|
||||
const wnd = projectWindows.createOrShow('#headerview/' + fn);
|
||||
if (wnd instanceof HeaderView && rec.line) {
|
||||
wnd.navigateToLine(rec.line);
|
||||
}
|
||||
} else if (rec.source === 'docs') {
|
||||
// External docs: open the URL in a new tab
|
||||
const url = rec.detail || rec.file || '';
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openSearchDialog() {
|
||||
const modal = bootbox.dialog({
|
||||
title: 'Search',
|
||||
onEscape: true,
|
||||
message: `
|
||||
<div id="searchbox_form">
|
||||
<input type="text" id="searchboxInput" class="form-control"
|
||||
placeholder="Search symbols, files, docs... (# symbols, > files, ? docs)"
|
||||
autocomplete="off" style="margin-bottom:8px">
|
||||
<div id="searchboxResults" style="max-height:340px;overflow-y:auto"></div>
|
||||
</div>
|
||||
`,
|
||||
buttons: {
|
||||
close: {
|
||||
label: "Close",
|
||||
className: "btn-default"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// bootbox auto-focuses the first accept button on shown; bind after it so
|
||||
// our focus wins (jQuery calls handlers in binding order).
|
||||
modal.one('shown.bs.modal', () => {
|
||||
$('#searchboxInput').trigger('focus');
|
||||
});
|
||||
|
||||
const input = $('#searchboxInput');
|
||||
|
||||
const resultsDiv = $('#searchboxResults');
|
||||
|
||||
const render = (hits: SearchHit[]) => {
|
||||
if (!hits || hits.length === 0) {
|
||||
resultsDiv.html('<div class="text-muted" style="padding:8px">No matches.</div>');
|
||||
return;
|
||||
}
|
||||
const ul = $('<ul class="list-group" style="margin-bottom:0"></ul>');
|
||||
for (const hit of hits) {
|
||||
const rec = hit.record;
|
||||
const srcName = rec.source;
|
||||
const kindIcon = rec.kind === 'func' ? 'ƒ' :
|
||||
rec.kind === 'macro' ? '#' :
|
||||
rec.kind === 'label' ? ':' :
|
||||
rec.kind === 'struct' ? 'S' :
|
||||
rec.kind === 'enum' ? 'E' :
|
||||
rec.kind === 'text' ? '≡' : '•';
|
||||
const loc = rec.file && rec.line ? `${rec.file}:${rec.line}` : (rec.file || '');
|
||||
const li = $('<li class="list-group-item search-hit" style="cursor:pointer;padding:6px 10px"></li>');
|
||||
li.html(
|
||||
`<span class="search-kind" style="margin-right:6px;font-weight:bold">${kindIcon}</span>` +
|
||||
`<strong>${escapeHtml(rec.name)}</strong>` +
|
||||
(rec.brief ? ` <span class="text-muted">- ${escapeHtml(rec.brief)}</span>` : '') +
|
||||
`<span class="pull-right text-muted" style="font-size:small">${escapeHtml(srcName)}${loc ? ' · ' + escapeHtml(loc) : ''}</span>`
|
||||
);
|
||||
li.click(() => {
|
||||
openSearchHit(hit);
|
||||
bootbox.hideAll();
|
||||
});
|
||||
ul.append(li);
|
||||
}
|
||||
resultsDiv.empty().append(ul);
|
||||
};
|
||||
|
||||
const doSearch = debounce(async () => {
|
||||
const needle = input.val() as string;
|
||||
if (!needle) {
|
||||
resultsDiv.empty();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hits = await searchService.query(needle, MAX_RESULTS);
|
||||
render(hits);
|
||||
} catch (e) {
|
||||
resultsDiv.html('<div class="text-danger" style="padding:8px">Search failed.</div>');
|
||||
}
|
||||
}, 150);
|
||||
|
||||
input.on('input', doSearch);
|
||||
input.on('keydown', (e) => {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Search Service - combines all search sources (project, toolchain, docs).
|
||||
* Provides unified search with query prefix support.
|
||||
*/
|
||||
|
||||
import { SearchHit, SearchSource } from "./types";
|
||||
import { SymbolSourceKind, SymbolRecord } from "../../common/searchtypes";
|
||||
import { ProjectSource } from "./projectsource";
|
||||
import { ToolchainSource } from "./toolchainsource";
|
||||
import { DocsSource } from "./docssource";
|
||||
|
||||
const MAX_RESULTS = 50;
|
||||
|
||||
export class SearchService {
|
||||
private static instance: SearchService;
|
||||
private sources: Map<string, SearchSource> = new Map();
|
||||
private projectSource!: ProjectSource;
|
||||
private toolchainSource!: ToolchainSource;
|
||||
private docsSource!: DocsSource;
|
||||
|
||||
private constructor() {
|
||||
this.projectSource = new ProjectSource();
|
||||
this.toolchainSource = new ToolchainSource();
|
||||
this.docsSource = new DocsSource();
|
||||
|
||||
this.sources.set('project', this.projectSource);
|
||||
this.sources.set('toolchain', this.toolchainSource);
|
||||
this.sources.set('docs', this.docsSource);
|
||||
}
|
||||
|
||||
static getInstance(): SearchService {
|
||||
if (!SearchService.instance) {
|
||||
SearchService.instance = new SearchService();
|
||||
}
|
||||
return SearchService.instance;
|
||||
}
|
||||
|
||||
/** Parse a query string and extract prefix modifiers */
|
||||
private parseQuery(query: string): { needle: string; filter: 'all' | 'symbols' | 'files' | 'docs' } {
|
||||
if (query.startsWith('#')) {
|
||||
return { needle: query.slice(1).trim(), filter: 'symbols' };
|
||||
} else if (query.startsWith('>')) {
|
||||
return { needle: query.slice(1).trim(), filter: 'files' };
|
||||
} else if (query.startsWith('?')) {
|
||||
return { needle: query.slice(1).trim(), filter: 'docs' };
|
||||
}
|
||||
return { needle: query, filter: 'all' };
|
||||
}
|
||||
|
||||
/** Execute a search query across all enabled sources */
|
||||
async query(queryStr: string, limit: number = MAX_RESULTS): Promise<SearchHit[]> {
|
||||
const { needle, filter } = this.parseQuery(queryStr);
|
||||
|
||||
if (!needle) return [];
|
||||
|
||||
const results: SearchHit[] = [];
|
||||
|
||||
// Determine which sources to query
|
||||
const sourcesToQuery: SearchSource[] = [];
|
||||
if (filter === 'docs') {
|
||||
sourcesToQuery.push(this.docsSource);
|
||||
} else if (filter === 'files') {
|
||||
sourcesToQuery.push(this.projectSource);
|
||||
} else if (filter === 'symbols') {
|
||||
sourcesToQuery.push(this.projectSource);
|
||||
sourcesToQuery.push(this.toolchainSource);
|
||||
} else {
|
||||
sourcesToQuery.push(this.projectSource);
|
||||
sourcesToQuery.push(this.toolchainSource);
|
||||
sourcesToQuery.push(this.docsSource);
|
||||
}
|
||||
|
||||
// Query all sources
|
||||
for (const source of sourcesToQuery) {
|
||||
try {
|
||||
await source.ready();
|
||||
const hits = await source.query(needle, limit - results.length);
|
||||
results.push(...hits);
|
||||
} catch (e) {
|
||||
console.debug('Search source error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (already scored by source)
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Apply source weights: project > toolchain > docs
|
||||
const sourceWeights: Record<string, number> = {
|
||||
project: 1.3,
|
||||
toolchain: 1.0,
|
||||
docs: 0.7
|
||||
};
|
||||
|
||||
for (const hit of results) {
|
||||
const weight = sourceWeights[hit.record.source as string] || 1.0;
|
||||
hit.score *= weight;
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Dedupe by ID
|
||||
const seen = new Set<string>();
|
||||
const deduped = results.filter(h => {
|
||||
if (seen.has(h.record.id)) return false;
|
||||
seen.add(h.record.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
return deduped.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const searchService = SearchService.getInstance();
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* SymbolIndex - shared machinery for searching a SymbolRecord[] corpus.
|
||||
* Uses uFuzzy for identifier lookup and MiniSearch for prose.
|
||||
*/
|
||||
|
||||
import { SymbolRecord, SymbolKind, SymbolSourceKind } from "../../common/searchtypes";
|
||||
import { SearchHit } from "./types";
|
||||
|
||||
const SOURCE_WEIGHTS: Record<SymbolSourceKind, number> = {
|
||||
project: 1.3,
|
||||
toolchain: 1.0,
|
||||
docs: 0.7,
|
||||
};
|
||||
|
||||
// Declarations (real code symbols) rank above docs; text hits rank lowest.
|
||||
const KIND_WEIGHTS: Record<SymbolKind, number> = {
|
||||
func: 1.2,
|
||||
macro: 1.2,
|
||||
type: 1.2,
|
||||
struct: 1.2,
|
||||
enum: 1.2,
|
||||
var: 1.1,
|
||||
label: 1.1,
|
||||
equate: 1.1,
|
||||
proc: 1.2,
|
||||
module: 1.2,
|
||||
doc: 0.8,
|
||||
text: 0.3,
|
||||
};
|
||||
|
||||
const PROSE_THRESHOLD = 4;
|
||||
// Base score for a uFuzzy match before quality/weight adjustments.
|
||||
// Domain: ~0-1000. Exact matches ~1000, fuzzy/degraded matches lower.
|
||||
const BASE_MATCH_SCORE = 1000;
|
||||
|
||||
/**
|
||||
* Helper to resolve a CommonJS module that may be exported as
|
||||
* `module.exports = Class` (uFuzzy, MiniSearch) into the class itself.
|
||||
* Works for both `import()` (bundled) and `require()` (tsc CJS output).
|
||||
*/
|
||||
function resolveClass(mod: any): any {
|
||||
if (!mod) return undefined;
|
||||
// Direct class export (module.exports = Class): static props only
|
||||
if (typeof mod === 'function') return mod;
|
||||
// ESM namespace: { default: Class }
|
||||
if (typeof mod.default === 'function') return mod.default;
|
||||
// Named export
|
||||
if (typeof mod.MiniSearch === 'function') return mod.MiniSearch;
|
||||
if (typeof mod.uFuzzy === 'function') return mod.uFuzzy;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class SymbolIndex {
|
||||
private records: SymbolRecord[] = [];
|
||||
private nameHaystack: string[] = [];
|
||||
private msIds: Set<string> = new Set();
|
||||
private uf: any;
|
||||
private ms: any;
|
||||
private applySourceBoosts: boolean;
|
||||
private ufOptions: any;
|
||||
private msOptions: any;
|
||||
|
||||
constructor(records: SymbolRecord[] = []) {
|
||||
this.records = records;
|
||||
this.nameHaystack = records.map(r => r.name);
|
||||
this.applySourceBoosts = true;
|
||||
this.ufOptions = {
|
||||
// Allow intra-word insertions so typos like "joy_rd" match "joy_read"
|
||||
intraIns: 2,
|
||||
interIns: 2,
|
||||
};
|
||||
this.msOptions = {
|
||||
idField: 'id',
|
||||
fields: ['name', 'brief', 'detail'],
|
||||
storeFields: ['id', 'name', 'kind', 'brief', 'detail', 'source', 'file', 'line'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Initialize indexers (uFuzzy + MiniSearch) */
|
||||
async init() {
|
||||
const ufMod = await import("@leeoniya/ufuzzy");
|
||||
const Ufuzzy = resolveClass(ufMod);
|
||||
if (Ufuzzy) {
|
||||
this.uf = new Ufuzzy(this.ufOptions);
|
||||
}
|
||||
|
||||
const msMod = await import("minisearch");
|
||||
const MiniSearch = resolveClass(msMod);
|
||||
if (MiniSearch) {
|
||||
this.ms = new MiniSearch(this.msOptions);
|
||||
// Re-add any records pushed before init().
|
||||
for (const rec of this.records) {
|
||||
this.addToMiniSearch(rec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add one record to MiniSearch, skipping duplicates by id. */
|
||||
private addToMiniSearch(rec: SymbolRecord) {
|
||||
if (!this.ms || typeof this.ms.add !== 'function') return;
|
||||
if (this.msIds.has(rec.id)) return;
|
||||
try {
|
||||
this.ms.add(rec);
|
||||
this.msIds.add(rec.id);
|
||||
} catch (e) {
|
||||
console.debug('MiniSearch add error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add new records to the index */
|
||||
pushRecords(records: SymbolRecord[]) {
|
||||
this.records.push(...records);
|
||||
this.nameHaystack.push(...records.map(r => r.name));
|
||||
// Add to MiniSearch if initialized
|
||||
for (const rec of records) {
|
||||
this.addToMiniSearch(rec);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all records */
|
||||
clear() {
|
||||
this.records = [];
|
||||
this.nameHaystack = [];
|
||||
this.msIds.clear();
|
||||
if (this.ms && typeof this.ms.removeAll === 'function') {
|
||||
this.ms.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load MiniSearch index from serialized JSON */
|
||||
async setMiniSearch(msJSON: any) {
|
||||
const msMod = await import("minisearch");
|
||||
const MiniSearch = resolveClass(msMod);
|
||||
if (!MiniSearch) return;
|
||||
this.ms = new MiniSearch(this.msOptions);
|
||||
this.ms.loadJS(msJSON);
|
||||
}
|
||||
|
||||
query(needle: string, limit: number): SearchHit[] {
|
||||
if (this.records.length === 0 || !needle) return [];
|
||||
|
||||
const hits: SearchHit[] = [];
|
||||
|
||||
// uFuzzy name search (priority)
|
||||
if (this.uf && typeof this.uf.search === 'function') {
|
||||
try {
|
||||
const result = this.uf.search(this.nameHaystack, needle);
|
||||
const info = result?.[1];
|
||||
const order = result?.[2];
|
||||
const srcIdxMap = info?.idx;
|
||||
if (srcIdxMap && order) {
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const ufuzzyOrderIdx = order[i];
|
||||
const srcIdx = srcIdxMap[ufuzzyOrderIdx];
|
||||
const record = this.records[srcIdx];
|
||||
if (!record) continue;
|
||||
// Score based on uFuzzy match quality, then source weight
|
||||
const base = this.baseScore(needle, info, ufuzzyOrderIdx);
|
||||
const score = base * SOURCE_WEIGHTS[record.source] * (KIND_WEIGHTS[record.kind] ?? 1);
|
||||
hits.push({ record, score, ranges: info.ranges?.[ufuzzyOrderIdx] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('uFuzzy search error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// MiniSearch prose search (if needle is long enough or has spaces)
|
||||
if (this.ms && typeof this.ms.search === 'function') {
|
||||
if (needle.length >= PROSE_THRESHOLD || needle.includes(' ')) {
|
||||
try {
|
||||
const msResults = this.ms.search(needle, {
|
||||
boost: { name: 3, brief: 1.5, detail: 1 },
|
||||
combineWith: 'AND',
|
||||
});
|
||||
for (const r of msResults) {
|
||||
const record = r;
|
||||
if (record && !hits.some(h => h.record.id === record.id)) {
|
||||
hits.push({
|
||||
record,
|
||||
score: (r.score || 0) * SOURCE_WEIGHTS[record.source] * KIND_WEIGHTS[r.kind] * 1000
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('MiniSearch error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Dedupe by ID
|
||||
const seen = new Set<string>();
|
||||
const deduped = hits.filter(h => {
|
||||
if (seen.has(h.record.id)) return false;
|
||||
seen.add(h.record.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
return deduped.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a match-quality score from uFuzzy info.
|
||||
* uFuzzy's `order` already sorts by quality (chars, intraIns, terms+prefix,
|
||||
* interIns, start, cases); we enrich it with source-weight and an absolute
|
||||
* scale so MiniSearch prose scores can be compared on the same axis.
|
||||
*/
|
||||
private baseScore(needle: string, info: any, i: number): number {
|
||||
const chars = info.chars?.[i] ?? 0;
|
||||
const interIns = info.interIns?.[i] ?? 0; // insertions between search terms
|
||||
const intraIns = info.intraIns?.[i] ?? 0; // insertions within terms (typos)
|
||||
const start = info.start?.[i] ?? 0; // offset of first match char
|
||||
const interLft2 = info.interLft2?.[i] ?? 0;
|
||||
const interRgt2 = info.interRgt2?.[i] ?? 0;
|
||||
|
||||
// chars >= needle length = full needle matched
|
||||
const fullMatch = chars >= needle.length;
|
||||
// No intra-word insertions and no inter-term insertions = contiguous
|
||||
const contiguous = intraIns === 0 && interIns === 0;
|
||||
// Match starts at position 0 = prefix match (strong signal for identifiers)
|
||||
const prefixMatch = start === 0;
|
||||
|
||||
let score = BASE_MATCH_SCORE;
|
||||
|
||||
// Exact match: full needle, contiguous, prefix
|
||||
if (fullMatch && contiguous && prefixMatch) score = 1000;
|
||||
// Prefix match that's fully contiguous but missing chars (shorter needle)
|
||||
else if (contiguous && prefixMatch) score = 980;
|
||||
// Full but non-contiguous (spread across name)
|
||||
else if (fullMatch) score = 950;
|
||||
// Fuzzy
|
||||
else score = 900;
|
||||
|
||||
// Each intra-word insertion (typo character) is expensive
|
||||
score -= intraIns * 20;
|
||||
// Inter-term insertions (underscores or gaps between terms)
|
||||
score -= interIns * 10;
|
||||
// Non-prefix start
|
||||
score -= start * 5;
|
||||
// Long tail to the right of the match means the match is not the whole name
|
||||
score -= interRgt2 * 3;
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { SymbolIndex } from "./symbolindex";
|
||||
import { SymbolSourceKind, PrebuiltIndex, SYMIDX_VERSION } from "../../common/searchtypes";
|
||||
import { SearchHit, SearchSource } from "./types";
|
||||
import { current_project } from "../ui";
|
||||
import { getSharedFileSystemName } from "../../common/toolmeta";
|
||||
|
||||
export class ToolchainSource implements SearchSource {
|
||||
id: string = 'toolchain';
|
||||
kind: SymbolSourceKind = 'toolchain';
|
||||
private indices: Map<string, SymbolIndex> = new Map();
|
||||
private corpusReady: Map<string, boolean> = new Map();
|
||||
|
||||
constructor() {}
|
||||
|
||||
async ready() {
|
||||
const project = current_project;
|
||||
if (!project) return;
|
||||
|
||||
const tool = project.getToolForFilename(project.mainPath);
|
||||
const corpus = getSharedFileSystemName(tool, project.platform_id);
|
||||
if (corpus) {
|
||||
await this.loadCorpus(corpus);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCorpus(corpus: string) {
|
||||
if (this.corpusReady.get(corpus)) return;
|
||||
|
||||
try {
|
||||
const url = `gen/symidx/${corpus}.json`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
this.corpusReady.set(corpus, true);
|
||||
return;
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.version !== SYMIDX_VERSION) {
|
||||
this.corpusReady.set(corpus, true);
|
||||
return;
|
||||
}
|
||||
const indexData = json as PrebuiltIndex;
|
||||
const idx = new SymbolIndex(indexData.records);
|
||||
await idx.init();
|
||||
if (indexData.ms) {
|
||||
await idx.setMiniSearch(indexData.ms);
|
||||
}
|
||||
this.indices.set(corpus, idx);
|
||||
this.corpusReady.set(corpus, true);
|
||||
} catch (e) {
|
||||
console.debug('Failed to load toolchain index:', e);
|
||||
this.corpusReady.set(corpus, true);
|
||||
}
|
||||
}
|
||||
|
||||
query(needle: string, limit: number): SearchHit[] {
|
||||
const hits: SearchHit[] = [];
|
||||
for (const idx of this.indices.values()) {
|
||||
const result = idx.query(needle, limit);
|
||||
hits.push(...result);
|
||||
}
|
||||
return hits.slice(0, limit);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.indices.clear();
|
||||
this.corpusReady.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SymbolRecord, SymbolSourceKind } from "../../common/searchtypes";
|
||||
|
||||
export interface SearchHit {
|
||||
record: SymbolRecord;
|
||||
score: number;
|
||||
ranges?: number[];
|
||||
}
|
||||
|
||||
export interface SearchSource {
|
||||
id: string;
|
||||
kind: SymbolSourceKind;
|
||||
ready(): Promise<void>;
|
||||
query(needle: string, limit: number): SearchHit[];
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
type: 'all' | 'symbols' | 'files' | 'docs';
|
||||
file?: string;
|
||||
needle: string;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { getRepos, parseGithubURL } from "./services";
|
||||
import { _downloadAllFilesZipFile, _downloadCassetteFile, _downloadProjectZipFile, _downloadROMImage, _downloadSourceFile, _downloadSymFile, _getCassetteFunction, _recordVideo, _shareEmbedLink } from "./shareexport";
|
||||
import { _importProjectFromGithub, _loginToGithub, _logoutOfGithub, _publishProjectToGithub, _pullProjectFromGithub, _pushProjectToGithub, _removeRepository, importProjectFromGithub } from "./sync";
|
||||
import { Toolbar } from "./toolbar";
|
||||
import { openSearchDialog } from "./search/searchview";
|
||||
import { setProjectProvider } from "./search/projectsource";
|
||||
import { AssetEditorView } from "./views/asseteditor";
|
||||
import { isMobileDevice } from "./views/baseviews";
|
||||
import { AddressHeatMapView, BinaryFileView, MemoryMapView, MemoryView, ProbeLogView, ProbeSymbolView, RasterStackMapView, ScanlineIOView, VRAMMemoryView } from "./views/debugviews";
|
||||
@@ -232,6 +234,7 @@ async function newFilesystem() {
|
||||
async function initProject() {
|
||||
var filesystem = await newFilesystem();
|
||||
current_project = new CodeProject(newWorker(), platform_id, platform, filesystem);
|
||||
setProjectProvider(() => current_project);
|
||||
current_project.remoteTool = qs.tool || null;
|
||||
projectWindows = new ProjectWindows($("#workspace")[0] as HTMLElement, current_project);
|
||||
current_project.callbackBuildResult = (result: WorkerResult) => {
|
||||
@@ -1597,6 +1600,7 @@ function setupDebugControls() {
|
||||
}
|
||||
uitoolbar.newGroup();
|
||||
uitoolbar.grp.prop('id', 'xtra_bar');
|
||||
uitoolbar.add('shift+ctrl+/', 'Search', 'glyphicon-search', openSearchDialog).prop('id', 'dbg_search');
|
||||
// add menu clicks
|
||||
$(".dropdown-menu").collapse({ toggle: false });
|
||||
$("#item_new_file").click(_createNewFile);
|
||||
@@ -1710,6 +1714,7 @@ function openKeyboardShortcuts() {
|
||||
message: `
|
||||
<table class="help">
|
||||
<tr><th colspan="2">Custom</th></tr>
|
||||
${shortcut(`${mod}+${shift}+F`, 'Search symbols, files, and docs')}
|
||||
${shortcut(`${shift}+${alt}+F`, 'Format document, or selected range(s)')}
|
||||
${shortcut('Tab', 'Insert to next tab stop, or indent selected range(s)')}
|
||||
${shortcut(`${shift}+Tab`, 'Outdent line(s) or selected range(s)')}
|
||||
|
||||
+20
-46
@@ -11,15 +11,7 @@ import { getFilenameForPath, getFolderForPath, hex, rpad } from "../../common/ut
|
||||
import { getIncludeDirs, getIncludePatterns, getLinkPatterns, getSharedFileSystemName, getSystemIncludePatterns } from "../../common/toolmeta";
|
||||
import { WorkerMessage } from "../../common/workertypes";
|
||||
import { SourceFile, SourceLocation, WorkerError } from "../../common/workertypes";
|
||||
import { asm6502 } from "../../parser/lang-6502";
|
||||
import { basic } from "../../parser/lang-basic";
|
||||
import { batariBasic } from "../../parser/lang-bataribasic";
|
||||
import { dialog } from "../../parser/lang-dialog";
|
||||
import { fastBasic } from "../../parser/lang-fastbasic";
|
||||
import { inform6 } from "../../parser/lang-inform6";
|
||||
import { verilog } from "../../parser/lang-verilog";
|
||||
import { wiz } from "../../parser/lang-wiz";
|
||||
import { asmZ80 } from "../../parser/lang-z80";
|
||||
import { parserRegistry, getLanguageSupportForStyle } from "../../parser/registry";
|
||||
import { cobalt } from "../../themes/cobalt";
|
||||
import { disassemblyTheme } from "../../themes/disassemblyTheme";
|
||||
import { editorTheme } from "../../themes/editorTheme";
|
||||
@@ -174,43 +166,12 @@ export class SourceEditor implements ProjectView {
|
||||
const minimalGutters = modedef.noGutters || isMobileDevice;
|
||||
|
||||
var parser: Extension;
|
||||
switch (this.mode) {
|
||||
case '6502':
|
||||
parser = asm6502();
|
||||
break;
|
||||
case 'basic':
|
||||
parser = basic();
|
||||
break;
|
||||
case 'bataribasic':
|
||||
parser = batariBasic();
|
||||
break;
|
||||
case 'fastbasic':
|
||||
parser = fastBasic();
|
||||
break;
|
||||
case 'dialog':
|
||||
parser = dialog();
|
||||
break;
|
||||
case 'inform6':
|
||||
parser = inform6();
|
||||
break;
|
||||
case 'markdown':
|
||||
parser = markdown();
|
||||
break;
|
||||
case 'text/x-csrc':
|
||||
parser = cpp();
|
||||
break;
|
||||
case 'text/x-wiz':
|
||||
parser = wiz();
|
||||
break;
|
||||
case 'verilog':
|
||||
parser = verilog();
|
||||
break;
|
||||
case 'z80':
|
||||
parser = asmZ80();
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown mode: " + this.mode);
|
||||
break;
|
||||
const registryEntry = parserRegistry[this.mode];
|
||||
if (registryEntry) {
|
||||
parser = registryEntry.language;
|
||||
} else {
|
||||
console.warn("Unknown mode: " + this.mode);
|
||||
parser = null;
|
||||
}
|
||||
this.editor = new EditorView({
|
||||
parent: parent,
|
||||
@@ -1070,6 +1031,19 @@ export class HeaderView implements ProjectView {
|
||||
+ '// Project include files are loaded during a build -- try building first.\n'
|
||||
+ '// Toolchain headers are only available when the tool has a bundled filesystem.');
|
||||
}
|
||||
|
||||
/** Jump to a line in the header (after content loads). */
|
||||
navigateToLine(line: number) {
|
||||
if (!this.view || line < 1) return;
|
||||
if (line <= this.view.state.doc.lines) {
|
||||
const target = this.view.state.doc.line(line);
|
||||
this.view.dispatch({
|
||||
selection: { anchor: target.from },
|
||||
effects: [EditorView.scrollIntoView(target.from, { y: "center" })],
|
||||
});
|
||||
this.view.focus();
|
||||
}
|
||||
}
|
||||
// track the most recent request so stale async lookups don't overwrite it
|
||||
private requestedFn: string;
|
||||
}
|
||||
@@ -163,6 +163,8 @@ function tokenString(stream: any, state: BasicState): string {
|
||||
return "string";
|
||||
}
|
||||
|
||||
export const basicStreamParser: StreamParser<BasicState> = basicParser;
|
||||
|
||||
export function basic(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(basicParser));
|
||||
return new LanguageSupport(StreamLanguage.define(basicStreamParser));
|
||||
}
|
||||
@@ -94,6 +94,8 @@ function createBatariBasicParser(): StreamParser<BatariBasicState> {
|
||||
/**
|
||||
* Language support for Batari Basic
|
||||
*/
|
||||
export const batariBasicStreamParser: StreamParser<BatariBasicState> = createBatariBasicParser();
|
||||
|
||||
export function batariBasic(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createBatariBasicParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(batariBasicStreamParser));
|
||||
}
|
||||
@@ -87,6 +87,8 @@ function createDialogParser(): StreamParser<DialogState> {
|
||||
/**
|
||||
* Language support for Dialog
|
||||
*/
|
||||
export const dialogStreamParser: StreamParser<DialogState> = createDialogParser();
|
||||
|
||||
export function dialog(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createDialogParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(dialogStreamParser));
|
||||
}
|
||||
@@ -165,6 +165,8 @@ function createFastBasicParser(): StreamParser<FastBasicState> {
|
||||
/**
|
||||
* Language support for FastBASIC
|
||||
*/
|
||||
export const fastBasicStreamParser: StreamParser<FastBasicState> = createFastBasicParser();
|
||||
|
||||
export function fastBasic(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createFastBasicParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(fastBasicStreamParser));
|
||||
}
|
||||
@@ -109,6 +109,8 @@ function createInform6Parser(): StreamParser<Inform6State> {
|
||||
/**
|
||||
* Language support for Inform6
|
||||
*/
|
||||
export const inform6StreamParser: StreamParser<Inform6State> = createInform6Parser();
|
||||
|
||||
export function inform6(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createInform6Parser()));
|
||||
return new LanguageSupport(StreamLanguage.define(inform6StreamParser));
|
||||
}
|
||||
@@ -460,16 +460,12 @@ function createVerilogParser(config: VerilogConfig = {}): StreamParser<VerilogSt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Language support for Verilog
|
||||
*/
|
||||
export const verilogStreamParser: StreamParser<VerilogState> = createVerilogParser();
|
||||
|
||||
export function verilog(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createVerilogParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(verilogStreamParser));
|
||||
}
|
||||
|
||||
/**
|
||||
* Language support for SystemVerilog
|
||||
*/
|
||||
export function systemverilog(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createVerilogParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(verilogStreamParser));
|
||||
}
|
||||
@@ -163,6 +163,8 @@ function createWizParser(): StreamParser<WizState> {
|
||||
/**
|
||||
* Language support for Wiz
|
||||
*/
|
||||
export const wizStreamParser: StreamParser<WizState> = createWizParser();
|
||||
|
||||
export function wiz(): LanguageSupport {
|
||||
return new LanguageSupport(StreamLanguage.define(createWizParser()));
|
||||
return new LanguageSupport(StreamLanguage.define(wizStreamParser));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Parser registry - central place to map editorStyle -> parser for both editor and extractor.
|
||||
// This keeps editors.ts and the indexer in sync.
|
||||
|
||||
import { StreamLanguage, StreamParser } from "@codemirror/language";
|
||||
import { LanguageSupport } from "@codemirror/language";
|
||||
import { cpp } from "@codemirror/lang-cpp";
|
||||
import { asm6502 } from "./lang-6502";
|
||||
import { asmZ80 } from "./lang-z80";
|
||||
import { wiz, wizStreamParser } from "./lang-wiz";
|
||||
import { verilog, verilogStreamParser } from "./lang-verilog";
|
||||
import { inform6, inform6StreamParser } from "./lang-inform6";
|
||||
import { dialog, dialogStreamParser } from "./lang-dialog";
|
||||
import { basic, basicStreamParser } from "./lang-basic";
|
||||
import { fastBasic, fastBasicStreamParser } from "./lang-fastbasic";
|
||||
import { batariBasic, batariBasicStreamParser } from "./lang-bataribasic";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
|
||||
export type SymbolKind =
|
||||
| 'func' | 'macro' | 'type' | 'struct' | 'enum' | 'var'
|
||||
| 'label' | 'equate' | 'proc' | 'module' | 'doc';
|
||||
|
||||
export type SymbolSourceKind = 'project' | 'toolchain' | 'docs';
|
||||
|
||||
export interface SymbolRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: SymbolKind;
|
||||
signature?: string;
|
||||
brief?: string;
|
||||
detail?: string;
|
||||
section?: string;
|
||||
source: SymbolSourceKind;
|
||||
file: string;
|
||||
line?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ToolDeclPattern { re: RegExp; group?: number; kind: SymbolKind; }
|
||||
|
||||
export interface ParserRegistryEntry {
|
||||
/** Language support for the editor (used by CodeMirror) */
|
||||
language: LanguageSupport;
|
||||
/** Raw StreamParser for the extractor (null if not available) */
|
||||
parser: StreamParser<any> | null;
|
||||
}
|
||||
|
||||
export const parserRegistry: Record<string, ParserRegistryEntry> = {
|
||||
'text/x-csrc': {
|
||||
language: cpp(),
|
||||
parser: null // Lezer-based, extracted via lrextract.ts
|
||||
},
|
||||
'6502': {
|
||||
language: asm6502(),
|
||||
parser: null // Lezer-based, extracted via lrextract.ts
|
||||
},
|
||||
z80: {
|
||||
language: asmZ80(),
|
||||
parser: null // Lezer-based, extracted via lrextract.ts
|
||||
},
|
||||
'text/x-wiz': {
|
||||
language: wiz(),
|
||||
parser: wizStreamParser
|
||||
},
|
||||
verilog: {
|
||||
language: verilog(),
|
||||
parser: verilogStreamParser
|
||||
},
|
||||
inform6: {
|
||||
language: inform6(),
|
||||
parser: inform6StreamParser
|
||||
},
|
||||
dialog: {
|
||||
language: dialog(),
|
||||
parser: dialogStreamParser
|
||||
},
|
||||
basic: {
|
||||
language: basic(),
|
||||
parser: basicStreamParser
|
||||
},
|
||||
fastbasic: {
|
||||
language: fastBasic(),
|
||||
parser: fastBasicStreamParser
|
||||
},
|
||||
bataribasic: {
|
||||
language: batariBasic(),
|
||||
parser: batariBasicStreamParser
|
||||
},
|
||||
markdown: {
|
||||
language: markdown(),
|
||||
parser: null
|
||||
},
|
||||
// Tier C - no parser available
|
||||
'6809': {
|
||||
language: null,
|
||||
parser: null
|
||||
},
|
||||
vasm: {
|
||||
language: null,
|
||||
parser: null
|
||||
},
|
||||
gas: {
|
||||
language: null,
|
||||
parser: null
|
||||
},
|
||||
ecs: {
|
||||
language: null,
|
||||
parser: null
|
||||
}
|
||||
};
|
||||
|
||||
export function getParserForStyle(editorStyle: string): StreamParser<any> | null {
|
||||
const entry = parserRegistry[editorStyle];
|
||||
return entry?.parser ?? null;
|
||||
}
|
||||
|
||||
export function getLanguageSupportForStyle(editorStyle: string): LanguageSupport | null {
|
||||
const entry = parserRegistry[editorStyle];
|
||||
return entry?.language ?? null;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import assert from "assert";
|
||||
import { describe, it } from "mocha";
|
||||
import { SymbolIndex } from "../../src/ide/search/symbolindex";
|
||||
import { SymbolRecord, SymbolSourceKind, SymbolKind } from "../../src/common/searchtypes";
|
||||
|
||||
function createTestRecord(
|
||||
name: string,
|
||||
kind: SymbolKind,
|
||||
source: SymbolSourceKind = 'project',
|
||||
brief?: string,
|
||||
detail?: string,
|
||||
file: string = 'test.c',
|
||||
line: number = 1
|
||||
): SymbolRecord {
|
||||
return {
|
||||
id: `${file}:${line}:${name}-${source}`, // Unique ID with source suffix
|
||||
name,
|
||||
kind,
|
||||
brief,
|
||||
detail,
|
||||
source,
|
||||
file,
|
||||
line
|
||||
};
|
||||
}
|
||||
|
||||
describe('SymbolIndex Search Ranking', function () {
|
||||
let index: SymbolIndex;
|
||||
|
||||
before(async function () {
|
||||
index = new SymbolIndex();
|
||||
|
||||
// Add test records with various match qualities
|
||||
const records: SymbolRecord[] = [
|
||||
// Exact name matches
|
||||
createTestRecord('joy_read', 'func', 'toolchain', 'Read joystick state'),
|
||||
createTestRecord('joy_write', 'func', 'toolchain', 'Write joystick state'),
|
||||
createTestRecord('JOY_BTN_1', 'macro', 'toolchain', 'Joystick button 1 mask'),
|
||||
createTestRecord('JOY_BTN_2', 'macro', 'toolchain', 'Joystick button 2 mask'),
|
||||
createTestRecord('JOY_DIR_LEFT', 'macro', 'toolchain', 'Left direction'),
|
||||
|
||||
// Project symbols (higher priority - same name, different source)
|
||||
createTestRecord('main', 'func', 'project', 'Main entry point'),
|
||||
createTestRecord('update', 'func', 'project', 'Game update loop'),
|
||||
createTestRecord('init', 'func', 'project', 'Initialize system'),
|
||||
|
||||
// Longer names with common prefixes
|
||||
createTestRecord('joy_read_button', 'func', 'toolchain', 'Read button state'),
|
||||
createTestRecord('joy_read_axis', 'func', 'toolchain', 'Read axis value'),
|
||||
createTestRecord('joy_read_all', 'func', 'toolchain', 'Read all inputs'),
|
||||
|
||||
// Partial matches
|
||||
createTestRecord('joystick_init', 'func', 'toolchain', 'Initialize joystick'),
|
||||
createTestRecord('joystick_poll', 'func', 'toolchain', 'Poll joystick'),
|
||||
createTestRecord('joystick_calibrate', 'func', 'toolchain', 'Calibrate joystick'),
|
||||
|
||||
// Docs
|
||||
createTestRecord('Getting Started', 'doc', 'docs', 'How to get started with 8bitworkshop'),
|
||||
createTestRecord('Joystick API', 'doc', 'docs', 'Joystick API reference'),
|
||||
createTestRecord('Memory Map', 'doc', 'docs', 'Memory map documentation'),
|
||||
];
|
||||
|
||||
index.pushRecords(records);
|
||||
await index.init();
|
||||
});
|
||||
|
||||
describe('uFuzzy name matching', function () {
|
||||
it('should rank exact matches first', async function () {
|
||||
const results = await index.query('joy_read', 10);
|
||||
|
||||
assert.ok(results.length >= 1, 'Should have at least one result');
|
||||
assert.strictEqual(results[0].record.name, 'joy_read', 'Exact match should be first');
|
||||
assert.strictEqual(results[0].record.kind, 'func');
|
||||
});
|
||||
|
||||
it('should rank prefix matches after exact', async function () {
|
||||
const results = await index.query('joy', 10);
|
||||
|
||||
// Should find all joy_* symbols
|
||||
const names = results.map(r => r.record.name);
|
||||
assert.ok(names.includes('joy_read'));
|
||||
assert.ok(names.includes('joy_write'));
|
||||
assert.ok(names.includes('joy_read_button'));
|
||||
assert.ok(names.includes('joy_read_axis'));
|
||||
assert.ok(names.includes('joy_read_all'));
|
||||
});
|
||||
|
||||
it('should handle fuzzy matching (joy_rd -> joy_read)', async function () {
|
||||
const results = await index.query('joy_rd', 10);
|
||||
|
||||
const names = results.map(r => r.record.name);
|
||||
assert.ok(names.includes('joy_read'), 'Should match joy_read with typo');
|
||||
});
|
||||
|
||||
it('should rank project symbols above toolchain for same name', async function () {
|
||||
// Add duplicate name with project source - same name, different source
|
||||
// The project source has weight 1.3 vs toolchain 1.0, so project should rank higher
|
||||
const projectJoy = createTestRecord('joy_read', 'func', 'project', 'Project joy read');
|
||||
index.pushRecords([projectJoy]);
|
||||
|
||||
const results = await index.query('joy_read', 10);
|
||||
|
||||
// First result should be project source (higher weight)
|
||||
const first = results.find(r => r.record.name === 'joy_read');
|
||||
assert.ok(first, 'Should find joy_read');
|
||||
assert.strictEqual(first.record.source, 'project', 'Project source should win for equal match quality');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', async function () {
|
||||
const results = await index.query('JOY_READ', 10);
|
||||
|
||||
const names = results.map(r => r.record.name);
|
||||
assert.ok(names.includes('joy_read'), 'Should match case-insensitively');
|
||||
});
|
||||
|
||||
it('should limit results to specified limit', async function () {
|
||||
const results = await index.query('joy', 3);
|
||||
|
||||
assert.ok(results.length <= 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source weight priority', function () {
|
||||
it('should prefer project over toolchain for equal match', async function () {
|
||||
// Add duplicate name with project source
|
||||
const projectMain = createTestRecord('main', 'func', 'project', 'Project main');
|
||||
const toolMain = createTestRecord('main', 'func', 'toolchain', 'Tool main');
|
||||
|
||||
// Both have same name but project has weight 1.3 > toolchain 1.0
|
||||
const testIndex = new SymbolIndex([projectMain, toolMain]);
|
||||
await testIndex.init();
|
||||
|
||||
const results = await testIndex.query('main', 10);
|
||||
|
||||
// Project should rank higher due to source weight
|
||||
assert.strictEqual(results[0].record.source, 'project');
|
||||
});
|
||||
|
||||
it('should prefer toolchain over docs for equal match', async function () {
|
||||
const toolMain = createTestRecord('getting_started', 'func', 'toolchain', 'Tool getting_started');
|
||||
const docMain = createTestRecord('Getting Started', 'doc', 'docs', 'Doc getting started');
|
||||
|
||||
const testIndex = new SymbolIndex([toolMain, docMain]);
|
||||
await testIndex.init();
|
||||
|
||||
const results = await testIndex.query('getting_started', 10);
|
||||
|
||||
// Toolchain should rank higher than docs (weight 1.0 > 0.7)
|
||||
assert.strictEqual(results[0].record.source, 'toolchain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MiniSearch prose search', function () {
|
||||
it('should find records by brief text', async function () {
|
||||
// search for a phrase in the brief field
|
||||
const results = await index.query('joystick state', 10);
|
||||
|
||||
const names = results.map(r => r.record.name);
|
||||
assert.ok(names.includes('joy_read') || results.length > 0, 'Should find at least one record by brief text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deduplication', function () {
|
||||
it('should not return duplicate records by ID', async function () {
|
||||
// Create index with duplicate IDs
|
||||
const dupRecords: SymbolRecord[] = [
|
||||
createTestRecord('test', 'func', 'project'),
|
||||
createTestRecord('test', 'func', 'project'), // duplicate
|
||||
];
|
||||
|
||||
const testIndex = new SymbolIndex(dupRecords);
|
||||
await testIndex.init();
|
||||
|
||||
const results = await testIndex.query('test', 10);
|
||||
|
||||
assert.strictEqual(results.length, 1, 'Should deduplicate by ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', function () {
|
||||
it('should return empty for empty query', async function () {
|
||||
const results = await index.query('', 10);
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('should return empty for empty index', async function () {
|
||||
const emptyIndex = new SymbolIndex([]);
|
||||
await emptyIndex.init();
|
||||
|
||||
const results = await emptyIndex.query('test', 10);
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('should handle special characters in needle', async function () {
|
||||
// Add record with special chars
|
||||
const special = createTestRecord('foo$bar', 'macro', 'project');
|
||||
index.pushRecords([special]);
|
||||
|
||||
const results = await index.query('foo$bar', 10);
|
||||
assert.ok(results.find(r => r.record.name === 'foo$bar'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectSource full-text search (straight scan of project file data)
|
||||
// ---------------------------------------------------------------------------
|
||||
import { ProjectSource, setProjectProvider } from '../../src/ide/search/projectsource';
|
||||
|
||||
describe('ProjectSource full-text search', function () {
|
||||
let ps: ProjectSource;
|
||||
|
||||
before(async function () {
|
||||
ps = new ProjectSource();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
setProjectProvider(() => null);
|
||||
});
|
||||
|
||||
it('should find matching lines in project file text (case-insensitive)', async function () {
|
||||
// register stub project with a couple of files
|
||||
setProjectProvider(() => ({
|
||||
mainPath: 'main.c',
|
||||
iterateFiles: (cb: (path: string, data: any) => void) => {
|
||||
cb('main.c', 'int main() {\n return 0;\n}\n');
|
||||
cb('game.s', 'foo: lda #$10\n sta $80\n');
|
||||
}
|
||||
}));
|
||||
|
||||
const hits = (ps as any).fulltextQuery('return', 10);
|
||||
assert.ok(hits.length >= 1, 'Should find return');
|
||||
const rec = hits[0].record;
|
||||
assert.strictEqual(rec.kind, 'text');
|
||||
assert.strictEqual(rec.source, 'project');
|
||||
assert.strictEqual(rec.file, 'main.c');
|
||||
assert.strictEqual(rec.line, 2);
|
||||
});
|
||||
|
||||
it('should not search binary files', async function () {
|
||||
setProjectProvider(() => ({
|
||||
mainPath: 'main.c',
|
||||
iterateFiles: (cb) => {
|
||||
cb('sprite.bin', new Uint8Array([0, 1, 2, 3]));
|
||||
cb('main.c', 'void setup() {}\n');
|
||||
}
|
||||
}));
|
||||
|
||||
const hits = (ps as any).fulltextQuery('setup', 10);
|
||||
assert.ok(hits.length >= 1);
|
||||
assert.strictEqual(hits[0].record.file, 'main.c');
|
||||
});
|
||||
|
||||
it('should require at least 2 chars', async function () {
|
||||
setProjectProvider(() => ({
|
||||
mainPath: 'main.c',
|
||||
iterateFiles: (cb) => cb('main.c', 'x\ny\n')
|
||||
}));
|
||||
const hits = (ps as any).fulltextQuery('x', 10);
|
||||
assert.strictEqual(hits.length, 0);
|
||||
});
|
||||
|
||||
it('should rank symbol hits above text hits', async function () {
|
||||
setProjectProvider(() => ({
|
||||
mainPath: 'main.c',
|
||||
iterateFiles: (cb) => cb('main.c', 'int counter = 0;\n')
|
||||
}));
|
||||
// stub index: symbol hit for counter
|
||||
(ps as any).index = {
|
||||
query: () => [{
|
||||
record: { id: 'main.c:1:counter', name: 'counter', kind: 'var', source: 'project', file: 'main.c', line: 1 },
|
||||
score: 980
|
||||
}]
|
||||
};
|
||||
|
||||
const hits = ps.query('counter', 10);
|
||||
assert.strictEqual(hits[0].record.kind, 'var', 'Symbol hit should rank first');
|
||||
assert.strictEqual(hits[0].score, 980);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "assert";
|
||||
import { describe, it } from "mocha";
|
||||
import { extractSymbols } from "../../src/common/symbols";
|
||||
import { extractDocComment, extractTrailingComment } from "../../src/common/symbols/doccomment";
|
||||
import { SymbolRecord } from "../../src/common/searchtypes";
|
||||
|
||||
describe('Symbol Extraction', function () {
|
||||
describe('Basic Symbol Extraction', function () {
|
||||
it('should extract #define macros from C/C++', function () {
|
||||
const text = `#define MAX_SIZE 100\n#define JOY_BTN_1_MASK 1\n`;
|
||||
const records = extractSymbols(text, 'test.h', 'text/x-csrc', 'project');
|
||||
|
||||
const defines = records.filter(r => r.kind === 'macro');
|
||||
assert.strictEqual(defines.length, 2);
|
||||
|
||||
const maxMacro = defines.find(r => r.name === 'MAX_SIZE');
|
||||
assert.ok(maxMacro, 'Should find MAX_SIZE macro');
|
||||
assert.strictEqual(maxMacro.name, 'MAX_SIZE');
|
||||
assert.strictEqual(maxMacro.kind, 'macro');
|
||||
|
||||
const joyMacro = defines.find(r => r.name === 'JOY_BTN_1_MASK');
|
||||
assert.ok(joyMacro, 'Should find JOY_BTN_1_MASK macro');
|
||||
assert.strictEqual(joyMacro.name, 'JOY_BTN_1_MASK');
|
||||
});
|
||||
|
||||
it('should extract 6502 labels', function () {
|
||||
const text = `foo: lda #$10\nbar: sta $80\n`;
|
||||
const records = extractSymbols(text, 'test.s', '6502', 'project');
|
||||
|
||||
const labels = records.filter(r => r.kind === 'label');
|
||||
assert.strictEqual(labels.length, 2);
|
||||
|
||||
const fooLabel = labels.find(r => r.name === 'foo');
|
||||
assert.ok(fooLabel, 'Should find foo label');
|
||||
assert.strictEqual(fooLabel.line, 1);
|
||||
|
||||
const barLabel = labels.find(r => r.name === 'bar');
|
||||
assert.ok(barLabel, 'Should find bar label');
|
||||
assert.strictEqual(barLabel.line, 2);
|
||||
});
|
||||
|
||||
it('should extract 6502 equates', function () {
|
||||
const text = `BAR = $1234\nFOO = $5678\n`;
|
||||
const records = extractSymbols(text, 'test.s', '6502', 'project');
|
||||
|
||||
const equates = records.filter(r => r.kind === 'equate');
|
||||
assert.strictEqual(equates.length, 2);
|
||||
|
||||
const barEquate = equates.find(r => r.name === 'BAR');
|
||||
assert.ok(barEquate, 'Should find BAR equate');
|
||||
assert.strictEqual(barEquate.name, 'BAR');
|
||||
|
||||
const fooEquate = equates.find(r => r.name === 'FOO');
|
||||
assert.ok(fooEquate, 'Should find FOO equate');
|
||||
assert.strictEqual(fooEquate.name, 'FOO');
|
||||
});
|
||||
|
||||
it('should not extract label-like text inside comments', function () {
|
||||
const text = `lda #$10 ; foo: this is a comment\nlabel:\n`;
|
||||
const records = extractSymbols(text, 'test.s', '6502', 'project');
|
||||
|
||||
const labels = records.filter(r => r.kind === 'label');
|
||||
assert.strictEqual(labels.length, 1);
|
||||
|
||||
const label = labels[0];
|
||||
assert.strictEqual(label.name, 'label', 'Should extract label, not colon from comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Doc comment extraction (Rule 3)', function () {
|
||||
it('should extract brief from plain comment immediately above', function () {
|
||||
const lines = [
|
||||
'/* The name of the standard joystick driver for a platform */',
|
||||
'extern const char joy_stddrv[];'
|
||||
];
|
||||
const doc = extractDocComment(lines, 2);
|
||||
assert.ok(doc, 'Should extract doc comment');
|
||||
assert.ok(doc.brief, 'Should have brief');
|
||||
assert.strictEqual(doc.brief, 'The name of the standard joystick driver for a platform', 'Brief should match comment text');
|
||||
});
|
||||
|
||||
it('should use trailing comment as brief', function () {
|
||||
const text = '#define MAX 1 /* maximum value */';
|
||||
const brief = extractTrailingComment(text);
|
||||
assert.ok(brief, 'Should extract trailing comment');
|
||||
assert.strictEqual(brief, 'maximum value', 'Brief should match comment text');
|
||||
});
|
||||
|
||||
it('should return null when no doc comments exist', function () {
|
||||
const lines = ['extern const char joy_stddrv[];'];
|
||||
const doc = extractDocComment(lines, 1);
|
||||
assert.strictEqual(doc, null, 'Should return null when no doc comments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-language extraction', function () {
|
||||
it('should handle wiz editor style (basic extraction)', function () {
|
||||
const text = `func main() {\n return 0;\n}\n`;
|
||||
const records = extractSymbols(text, 'test.wiz', 'text/x-wiz', 'project');
|
||||
assert.ok(Array.isArray(records), 'Should return array for wiz extraction');
|
||||
});
|
||||
|
||||
it('should handle dialog editor style (basic extraction)', function () {
|
||||
const text = `@@ Clear, bright, and spacious\n!(kitchen) A kitchen.`;
|
||||
const records = extractSymbols(text, 'game.dlg', 'dialog', 'project');
|
||||
assert.ok(Array.isArray(records), 'Should return array for dialog extraction');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SymbolRecord Type', function () {
|
||||
it('should create valid SymbolRecord', function () {
|
||||
const record: SymbolRecord = {
|
||||
id: 'test.c:1:main',
|
||||
name: 'main',
|
||||
kind: 'func',
|
||||
source: 'project',
|
||||
file: 'test.c',
|
||||
line: 1
|
||||
};
|
||||
|
||||
assert.strictEqual(record.id, 'test.c:1:main');
|
||||
assert.strictEqual(record.name, 'main');
|
||||
assert.strictEqual(record.kind, 'func');
|
||||
assert.strictEqual(record.source, 'project');
|
||||
assert.strictEqual(record.file, 'test.c');
|
||||
assert.strictEqual(record.line, 1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user