vscode: tweaks to detection logic

This commit is contained in:
Steven Hugg committed 2026-09-26 17:44:48 +02:00
1 parent 94e331a43a
commit b0f1d496e6
5 files changed
+96 -17

No files matched your search

+7
View File
@@ -38,6 +38,13 @@ Strongest first:
Plain C or assembly with none of these never brings up a notification,
so other projects you open aren't interrupted.
Hardware registers and function names in a header only declare a
library, so a folder of nothing but headers and library sources isn't
offered as a project. When a folder holds several programs, its review
entry shows the count (**Game Boy · 26 programs**). Accepting it makes
the folder a directory of small programs, so **Run** builds whichever
program is open.
Some platforms share everything detection can see. For example, the
ColecoVision, MSX and Sega platforms all use `cv.h`. Then the list shows
them all and you choose.
+13 -10
View File
@@ -4,7 +4,8 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { Detection, detectProject, isBuildableSource, isClearWinner, isStrongDetection, toolForDialect } from '../../src/common/detect';
import { Detection, detectProject, detectionSummary, describeDetection, describeFinding, isBuildableSource, isClearWinner, isFolderOfPrograms, isStrongDetection, toolForDialect } from '../../src/common/detect';
export { detectionSummary, describeDetection, describeFinding, isFolderOfPrograms };
import { PLATFORM_PARAMS } from '../../src/worker/platforms';
import { getToolForPlatform } from '../../src/common/toolselect';
import { isProbablyBinary } from '../../src/common/util';
@@ -33,12 +34,6 @@ export function platformName(templates: Templates, id: string): string {
}
}
function describe(d: Detection): string {
var ev = d.evidence[0];
if (!ev) return '';
return `${ev.file === '.' ? 'folder' : ev.file}${ev.line ? ':' + ev.line : ''} ${ev.reason}`;
}
async function readText(uri: vscode.Uri): Promise<string | null> {
if (isProbablyBinary(uri.path)) return null;
var doc = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString());
@@ -91,7 +86,7 @@ export async function chooseForFile(templates: Templates, file: vscode.Uri): Pro
if (isClearWinner(found)) {
platform = found[0].platform;
// the user asked, so apply it now and offer a way out
vscode.window.showInformationMessage(`Using ${platformName(templates, platform)} (${describe(found[0])}).`, 'Change')
vscode.window.showInformationMessage(`Using ${platformName(templates, platform)} (${describeDetection(found[0])}).`, 'Change')
.then(async a => {
if (a === 'Change') vscode.commands.executeCommand('8bitworkshop.selectPlatform');
});
@@ -115,7 +110,7 @@ export async function pickPlatformWithEvidence(templates: Templates, found: Dete
items.push({ label: 'Detected', kind: vscode.QuickPickItemKind.Separator });
for (var d of candidates) {
shown.add(d.platform);
items.push({ label: platformName(templates, d.platform), description: d.platform, detail: describe(d), id: d.platform });
items.push({ label: platformName(templates, d.platform), description: d.platform, detail: describeDetection(d), id: d.platform });
}
items.push({ label: 'All platforms', kind: vscode.QuickPickItemKind.Separator });
}
@@ -173,11 +168,19 @@ export async function scanFolder(templates: Templates, folder: vscode.WorkspaceF
findings.push({ dir: vscode.Uri.file(dir), detection: found[0] });
}
}
// a folder of programs already covers a nested library folder inside it;
// a nested project with its own main file stays
findings = findings.filter(f => !findings.some(p =>
p !== f && isFolderOfPrograms(p.detection) && !f.detection.mainFile && isInsideDir(p.dir.fsPath, f.dir.fsPath)));
return findings;
}
function isInsideDir(parent: string, child: string): boolean {
var rel = path.relative(parent, child);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
export function dontAskKey(folder: vscode.WorkspaceFolder) {
return DONT_ASK + folder.uri.toString();
}
export { describe as describeDetection };
+5 -5
View File
@@ -14,7 +14,7 @@ import { Project, findRootDir, isHeaderFile, isInside, isOwnExtension, isSourceF
import { CONFIG, ProjectScope } from './projectscope';
import { BuildReason, BuildScheduler } from './autobuild';
import { PRESET_SCHEME, PresetFileSystem, Templates } from './templates';
import { chooseForFile, describeDetection, dontAskKey, platformName, scanFolder } from './detection';
import { chooseForFile, detectionSummary, describeFinding, dontAskKey, platformName, scanFolder } from './detection';
import { TOOL_META } from '../../src/common/toolmeta';
let context: vscode.ExtensionContext;
@@ -726,7 +726,7 @@ async function detectProjects(asked: boolean) {
var f = findings[0], d = f.detection;
var where = path.relative(folder.uri.fsPath, f.dir.fsPath);
var answer = await vscode.window.showInformationMessage(
`This looks like a${/^[aeiou]/i.test(platformName(templates, d.platform)) ? 'n' : ''} ${platformName(templates, d.platform)} project${where ? ' in ' + where : ''} (${describeDetection(d)}).`,
`This looks like a${/^[aeiou]/i.test(platformName(templates, d.platform)) ? 'n' : ''} ${platformName(templates, d.platform)} project${where ? ' in ' + where : ''} (${describeFinding(d)}).`,
'Use It', 'Choose...', 'Not Now', "Don't Ask");
if (answer === 'Use It') await acceptFinding(f.dir.fsPath, d.platform, d.mainFile, d.tool);
else if (answer === 'Choose...') {
@@ -740,9 +740,9 @@ async function detectProjects(asked: boolean) {
if (review !== 'Review') continue;
type Item = vscode.QuickPickItem & { f: typeof findings[0] };
var items: Item[] = findings.map(f => ({
label: path.relative(folder.uri.fsPath, f.dir.fsPath) || folder.name,
description: `${platformName(templates, f.detection.platform)}${f.detection.mainFile ? ' · ' + f.detection.mainFile : ''}`,
detail: describeDetection(f.detection), picked: true, f,
label: path.relative(folder.uri.fsPath, f.dir.fsPath) || '.',
description: `${platformName(templates, f.detection.platform)}${detectionSummary(f.detection) ? ' · ' + detectionSummary(f.detection) : ''}`,
detail: describeFinding(f.detection), picked: true, f,
}));
var chosen = await vscode.window.showQuickPick(items, { canPickMany: true, title: 'Use these as 8bitworkshop projects', matchOnDetail: true });
for (var c of chosen || []) await acceptFinding(c.f.dir.fsPath, c.f.detection.platform, c.f.detection.mainFile, c.f.detection.tool);
+46 -1
View File
@@ -50,6 +50,7 @@ export const ROM_PLATFORMS: { [ext: string]: string } = {
const SYSTEM_HEADERS: { [header: string]: string[] } = {
'nes.h': ['nes'],
'neslib.h': ['nes'],
'apu.h': ['nes'],
'cv.h': ['coleco', 'msx-libcv', 'sms-sg1000-libcv', 'sms-sms-libcv', 'sms-gg-libcv'],
'cvu.h': ['coleco', 'msx-libcv', 'sms-sg1000-libcv', 'sms-sms-libcv', 'sms-gg-libcv'],
'c64.h': ['c64'],
@@ -63,6 +64,7 @@ const SYSTEM_HEADERS: { [header: string]: string[] } = {
'pce.h': ['pce'],
'gb/gb.h': ['gb'],
'gb.h': ['gb'],
'gbtext.h': ['gb'],
'vcs.h': ['vcs'],
'macro.h': ['vcs'],
'vectrex.h': ['vectrex'],
@@ -88,6 +90,9 @@ const FINGERPRINTS: { re: RegExp, platforms: string[], reason: string }[] = [
];
const HEADER_EXTS = ['.h', '.inc', '.i'];
// a register or function name in a header is a declaration, not a use, so
// it points at the library rather than at a program written for the platform
const HEADER_FINGERPRINT_WEIGHT = 0.15;
// extensions so many tools use that they say nothing about the platform
const GENERIC_EXTS = ['.c', '.h', '.s', '.asm', '.a', '.inc', '.i', '.bas'];
@@ -229,7 +234,8 @@ export async function detectProject(input: DetectInput): Promise<Detection[]> {
var fm = fp.re.exec(text);
if (fm) {
var fline = text.substring(0, fm.index).split('\n').length;
addAll(fp.platforms, 0.5, { file, line: fline, reason: fp.reason });
var fweight = HEADER_EXTS.includes(ext) ? HEADER_FINGERPRINT_WEIGHT : 0.5;
addAll(fp.platforms, fweight, { file, line: fline, reason: fp.reason });
}
}
}
@@ -282,6 +288,45 @@ export function isStrongDetection(d: Detection): boolean {
return d.score >= 0.5;
}
/** True for a header or include file: a declaration, not a program. */
export function isHeaderFile(fn: string): boolean {
return HEADER_EXTS.includes(extname(fn));
}
/** The clue to show first: a program source's, unless only a header matches. */
export function mainEvidence(d: Detection): DetectEvidence | undefined {
return d.evidence.find(e => e.file !== '.' && !isHeaderFile(e.file)) || d.evidence[0];
}
/** "game.c", or "26 programs" for a directory of programs. */
export function detectionSummary(d: Detection): string {
if (d.mainFile) return d.mainFile;
var n = d.mainCandidates?.length || 0;
return n > 1 ? `${n} programs` : '';
}
/** True for a directory that holds several programs and no single main file. */
export function isFolderOfPrograms(d: Detection): boolean {
return !d.mainFile && (d.mainCandidates?.length || 0) > 1;
}
/** A one-line description of the clue behind a detection. */
export function describeDetection(d: Detection): string {
var ev = mainEvidence(d);
if (!ev) return '';
return `${ev.file === '.' ? 'folder' : ev.file}${ev.line ? ':' + ev.line : ''} ${ev.reason}`;
}
/** The clue, and for a folder of programs the number of programs it holds. */
export function describeFinding(d: Detection): string {
var text = describeDetection(d);
if (isFolderOfPrograms(d)) {
var n = d.mainCandidates!.length;
text = `${n} programs${text ? ' — ' + text : ''}`;
}
return text;
}
/**
* The files that look like programs: buildable sources no other file
* includes or links. `mainFile` is set when one stands out; when several
+25 -1
View File
@@ -2,7 +2,7 @@ import assert from "assert";
import { describe, it } from "mocha";
import * as fs from "fs";
import * as path from "path";
import { detectProject, detectDialect, findMainCandidates, headersFromPresets, isClearWinner, parseReadmeBadge, toolForDialect } from "../../src/common/detect";
import { detectProject, detectDialect, detectionSummary, describeFinding, findMainCandidates, headersFromPresets, isClearWinner, isFolderOfPrograms, isHeaderFile, mainEvidence, parseReadmeBadge, toolForDialect } from "../../src/common/detect";
import { PLATFORM_PARAMS } from "../../src/worker/platforms";
const PRESETS = 'presets';
@@ -72,6 +72,30 @@ describe('detect', () => {
assert.deepStrictEqual(findMainCandidates('nes', [...texts.keys()], texts), { candidates: ['a.c', 'b.c'], mainFile: undefined });
});
it('downweights a fingerprint found in a header', async () => {
// the GBDK calls are library declarations, not a program using the hardware
var header = 'void display_off();\nvoid SHOW_BKG();\nvoid set_bkg_data();\n';
var d = await detectProject({ files: ['gb.h'], read: () => header, platforms: PLATFORMS, dirName: 'gb' });
assert.ok(d.every(x => x.score < 0.5), JSON.stringify(d));
assert.ok(d[0].score > 0, 'the header still counts for something');
assert.ok(isHeaderFile('gb/gb.h'));
assert.ok(!isHeaderFile('gb/gb.sgb'));
assert.strictEqual(mainEvidence(d[0])?.reason, 'calls GBDK functions');
});
it('lists every program in a folder of programs', async () => {
var programs = ['chase.c', 'climber.c', 'testphys.c'];
var map: { [fn: string]: string } = {};
for (var fn of programs) map[fn] = '#include "gb/gb.h"\nvoid main() {}\n';
var d = await detectProject({ files: programs, read: (fn) => map[fn] ?? null, platforms: PLATFORMS, headers: { 'gb/gb.h': ['gb'] }, dirName: 'gb' });
assert.strictEqual(d[0].platform, 'gb');
assert.strictEqual(d[0].mainFile, undefined);
assert.deepStrictEqual(d[0].mainCandidates, programs);
assert.ok(isFolderOfPrograms(d[0]));
assert.strictEqual(detectionSummary(d[0]), '3 programs');
assert.strictEqual(describeFinding(d[0]), '3 programs — chase.c:1 includes "gb/gb.h"');
});
// Every preset's true platform is its directory. Track how often the top
// guess for a lone preset file is right, and fail if it gets worse.
it('guesses the platform of the presets', async function () {