diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 00000000..c92a7d36 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,3 @@ +out/ +node_modules/ +*.vsix diff --git a/extension/.vscode/launch.json b/extension/.vscode/launch.json new file mode 100644 index 00000000..2d68159e --- /dev/null +++ b/extension/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Open the extension/ folder in VS Code and press F5. + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}", "${workspaceFolder}/../presets/nes"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "build" + } + ] +} diff --git a/extension/.vscode/tasks.json b/extension/.vscode/tasks.json new file mode 100644 index 00000000..fee838ed --- /dev/null +++ b/extension/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "npm", + "script": "build", + "group": "build", + "problemMatcher": [] + } + ] +} diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 00000000..ba51d048 --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "8bitworkshop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "8bitworkshop", + "version": "0.1.0", + "license": "GPL-3.0", + "devDependencies": { + "@types/vscode": "~1.90.0" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz", + "integrity": "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 00000000..111eccd6 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,76 @@ +{ + "name": "8bitworkshop", + "displayName": "8bitworkshop", + "description": "Build and run retro 8-bit programs with the 8bitworkshop toolchains and emulators", + "version": "0.1.0", + "publisher": "8bitworkshop", + "license": "GPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/sehugg/8bitworkshop.git" + }, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Programming Languages", + "Debuggers" + ], + "main": "./out/extension.js", + "activationEvents": [ + "onStartupFinished" + ], + "contributes": { + "commands": [ + { + "command": "8bitworkshop.build", + "title": "Build", + "category": "8bitworkshop" + }, + { + "command": "8bitworkshop.selectPlatform", + "title": "Select Platform", + "category": "8bitworkshop" + } + ], + "configuration": { + "title": "8bitworkshop", + "properties": { + "8bitworkshop.platform": { + "type": "string", + "default": "", + "description": "Target platform id (e.g. c64, nes, vcs, gb)." + }, + "8bitworkshop.mainFile": { + "type": "string", + "default": "", + "description": "Main source file, relative to the workspace folder. Defaults to the active editor's file." + }, + "8bitworkshop.tool": { + "type": "string", + "default": "", + "description": "Tool for the main file. Defaults to the platform's tool for the file extension." + }, + "8bitworkshop.buildOnSave": { + "type": "boolean", + "default": true, + "description": "Build when a source file or one of its dependencies is saved." + }, + "8bitworkshop.toolchainPath": { + "type": "string", + "default": "", + "description": "Directory containing src/worker (toolchain assets). Defaults to the extension's own copy." + } + } + } + }, + "scripts": { + "build": "node scripts/build.mjs", + "watch": "node scripts/build.mjs --watch", + "typecheck": "tsc --noEmit -p .", + "test": "node scripts/build.mjs && mocha --timeout 60000 out/test/*.test.js" + }, + "devDependencies": { + "@types/vscode": "~1.90.0" + } +} diff --git a/extension/scripts/build.mjs b/extension/scripts/build.mjs new file mode 100644 index 00000000..de57517d --- /dev/null +++ b/extension/scripts/build.mjs @@ -0,0 +1,40 @@ +// Bundles the extension with esbuild (from the repo's node_modules). +// +// node scripts/build.mjs # one build +// node scripts/build.mjs --watch # rebuild on change +// +// extension.js stays small; it loads buildcore.js on the first build. +import esbuild from 'esbuild'; +import { readdirSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const watch = process.argv.includes('--watch'); +const tests = readdirSync(path.join(root, 'test')) + .filter((f) => f.endsWith('.test.ts')) + .map((f) => [`test/${f.slice(0, -3)}`, `test/${f}`]); + +const ctx = await esbuild.context({ + absWorkingDir: root, + entryPoints: { + extension: 'src/extension.ts', + buildcore: 'src/buildcore.ts', + ...Object.fromEntries(tests), + }, + outdir: 'out', + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + sourcemap: true, + external: ['vscode'], + logLevel: 'warning', +}); +if (watch) { + await ctx.watch(); + console.error('[esbuild] watching extension'); +} else { + await ctx.rebuild(); + await ctx.dispose(); +} diff --git a/extension/src/buildcore.ts b/extension/src/buildcore.ts new file mode 100644 index 00000000..abfd49ad --- /dev/null +++ b/extension/src/buildcore.ts @@ -0,0 +1,167 @@ + +// buildcore - compiles a project in-process with the worker build system. +// No vscode imports, so tests can run it under plain Node. + +import * as fs from 'fs'; +import * as path from 'path'; +import type { CodeListingMap, FileData, Segment, WorkerError, WorkerResult } from "../../src/common/workertypes"; +import { getBasePlatform, isProbablyBinary } from "../../src/common/util"; +import { getToolForPlatform } from "../../src/common/toolselect"; +import { TOOL_META } from "../../src/common/toolmeta"; +import { FileProvider, buildWorkerMessage, processListings, resolveDependencies } from "../../src/common/projectcore"; +import { setupNodeEnvironment, handleMessage } from "../../src/worker/workerlib"; +import { PLATFORM_PARAMS } from "../../src/worker/platforms"; + +export type { FileProvider }; + +export interface BuildRequest { + platform: string; + /** main file, relative to the project root (posix separators) */ + mainPath: string; + mainText: string; + files: FileProvider; + /** overrides the tool for the main file */ + tool?: string; +} + +export interface BuildDiagnostic { + /** project-relative path */ + path: string; + /** 1-based line number (0 = no line) */ + line: number; + msg: string; +} + +export interface BuildOutcome { + success: boolean; + tool: string; + output?: Uint8Array; + diagnostics: BuildDiagnostic[]; + listings?: CodeListingMap; + symbolmap?: { [sym: string]: number }; + segments?: Segment[]; + /** every project path the build read */ + paths: string[]; + /** true when no inputs changed since the previous build */ + unchanged?: boolean; +} + +/** + * Runs builds against the toolchain assets under `rootDir/src/worker`. + * The worker store is global, so builds run one at a time. + */ +export class Builder { + private queue: Promise = Promise.resolve(); + private initialized = false; + private preloaded = new Set(); + + constructor(readonly rootDir: string) { } + + build(req: BuildRequest): Promise { + var next = this.queue.then(() => this.doBuild(req)); + this.queue = next.catch(() => { }); + return next; + } + + private async doBuild(req: BuildRequest): Promise { + if (!this.initialized) { + setupNodeEnvironment(this.rootDir); + this.initialized = true; + } + var getTool = (fn: string) => (req.tool && fn === req.mainPath) ? req.tool : getToolForPlatform(req.platform, fn); + var tool = getTool(req.mainPath); + var deps = await resolveDependencies(req.files, req.mainPath, req.mainText, req.platform, getTool); + var { msg, filename2path, preloads } = buildWorkerMessage({ + mainPath: req.mainPath, + mainData: req.mainText, + platformId: req.platform, + getToolForFilename: getTool, + }, deps); + var paths = [req.mainPath].concat(deps.map(d => d.path)); + var result: WorkerResult; + try { + for (var t of preloads) { + if (!this.preloaded.has(t + "/" + req.platform)) { + await handleMessage({ preload: t, platform: req.platform } as any); + this.preloaded.add(t + "/" + req.platform); + } + } + result = await handleMessage(msg); + } catch (e) { + return { success: false, tool, paths, diagnostics: [{ path: req.mainPath, line: 0, msg: String(e && e.message || e) }] }; + } + if (!result || ('unchanged' in result && result.unchanged)) { + return { success: true, tool, paths, diagnostics: [], unchanged: true }; + } + if ('errors' in result && result.errors && result.errors.length) { + var toPath = (err: WorkerError) => (err.path && filename2path[err.path]) || err.path || req.mainPath; + return { + success: false, tool, paths, + diagnostics: result.errors.map(err => ({ path: toPath(err), line: err.line || 0, msg: err.msg })), + }; + } + if ('output' in result) { + var r = result as any; + if (r.listings) processListings(r.listings); + return { + success: true, tool, paths, diagnostics: [], + output: r.output, listings: r.listings, symbolmap: r.symbolmap, segments: r.segments, + }; + } + return { success: false, tool, paths, diagnostics: [{ path: req.mainPath, line: 0, msg: 'Unknown build result' }] }; + } +} + +/** + * Reads project files through `read`, then falls back to the bundled + * presets/ directory (for shared headers and libraries). + */ +export class ProjectFileProvider implements FileProvider { + constructor( + readonly read: (relpath: string) => Promise, + readonly rootDir: string, + readonly platform: string) { } + + async readFile(relpath: string): Promise { + var data = await this.read(relpath); + if (data == null) { + var p = path.resolve(this.rootDir, 'presets', getBasePlatform(this.platform), relpath); + try { + if (fs.statSync(p).isFile()) data = new Uint8Array(fs.readFileSync(p)); + } catch (e) { + return null; + } + } + if (data == null) return null; + return isProbablyBinary(relpath) ? data : new TextDecoder().decode(data); + } +} + +export function listPlatforms(): string[] { + return Object.keys(PLATFORM_PARAMS).sort(); +} + +var sourceExtensions: Set; + +/** True if some tool consumes files with this name's extension. */ +export function isSourceFile(fn: string): boolean { + if (!sourceExtensions) { + sourceExtensions = new Set(['.asm', '.a', '.inc', '.h', '.s', '.c', '.bas']); + for (var id in TOOL_META) + for (var ext of TOOL_META[id].extensions || []) + sourceExtensions.add(ext.toLowerCase()); + } + return sourceExtensions.has(path.extname(fn).toLowerCase()); +} + +/** The asset root: a directory with src/worker, searched upward from `start`. */ +export function findRootDir(start: string): string | null { + var dir = path.resolve(start); + for (var i = 0; i < 4; i++) { + if (fs.existsSync(path.join(dir, 'src', 'worker', 'wasm'))) return dir; + var parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 00000000..603f640c --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,192 @@ + +// 8bitworkshop VS Code extension entry point. +// Keep this module light: the build system loads on the first build. + +import * as vscode from 'vscode'; +import * as path from 'path'; +import type * as BuildCore from './buildcore'; + +const CONFIG = '8bitworkshop'; + +let buildcore: typeof BuildCore; +let builder: BuildCore.Builder; +let output: vscode.OutputChannel; +let diagnostics: vscode.DiagnosticCollection; +let status: vscode.StatusBarItem; +let lastMain: vscode.Uri | undefined; +let lastPaths = new Set(); // fsPaths the last build read +let lastBuild: BuildCore.BuildOutcome | undefined; +let pending: NodeJS.Timeout | undefined; + +export function activate(context: vscode.ExtensionContext) { + output = vscode.window.createOutputChannel('8bitworkshop'); + diagnostics = vscode.languages.createDiagnosticCollection('8bitworkshop'); + status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + status.command = '8bitworkshop.selectPlatform'; + context.subscriptions.push(output, diagnostics, status); + updateStatus(); + + context.subscriptions.push( + vscode.commands.registerCommand('8bitworkshop.build', () => buildCommand(context)), + vscode.commands.registerCommand('8bitworkshop.selectPlatform', () => selectPlatform(context)), + vscode.workspace.onDidSaveTextDocument(doc => onSave(context, doc)), + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CONFIG)) updateStatus(); + }), + ); +} + +export function deactivate() { + if (pending) clearTimeout(pending); +} + +function config() { + return vscode.workspace.getConfiguration(CONFIG); +} + +function updateStatus() { + var platform = config().get('platform'); + status.text = '$(chip) ' + (platform || 'No platform'); + status.tooltip = 'Select 8bitworkshop platform'; + status.show(); +} + +function loadBuildCore(context: vscode.ExtensionContext): typeof BuildCore { + if (!buildcore) { + // a computed path keeps esbuild from inlining it + buildcore = require(path.join(__dirname, 'buildcore')); + var configured = config().get('toolchainPath'); + var root = configured || buildcore.findRootDir(context.extensionPath); + if (!root) throw new Error('Cannot find toolchain assets (src/worker). Set 8bitworkshop.toolchainPath.'); + output.appendLine(`Toolchain root: ${root}`); + builder = new buildcore.Builder(root); + } + return buildcore; +} + +async function selectPlatform(context: vscode.ExtensionContext): Promise { + var core = loadBuildCore(context); + var picked = await vscode.window.showQuickPick(core.listPlatforms(), { placeHolder: 'Target platform' }); + if (!picked) return undefined; + var target = vscode.workspace.workspaceFolders ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global; + await config().update('platform', picked, target); + return picked; +} + +async function buildCommand(context: vscode.ExtensionContext) { + var platform = config().get('platform') || await selectPlatform(context); + if (!platform) return; + var main = findMainFile(context); + if (!main) { + vscode.window.showWarningMessage('8bitworkshop: open a source file or set 8bitworkshop.mainFile.'); + return; + } + var result = await runBuild(context, platform, main); + if (result && !result.success) output.show(true); +} + +function onSave(context: vscode.ExtensionContext, doc: vscode.TextDocument) { + var platform = config().get('platform'); + if (!platform || !config().get('buildOnSave')) return; + if (doc.uri.scheme === 'untitled') return; + var isDep = lastPaths.has(doc.uri.fsPath); + if (!isDep && !loadBuildCore(context).isSourceFile(doc.fileName)) return; + if (pending) clearTimeout(pending); + pending = setTimeout(() => { + pending = undefined; + // a saved dependency rebuilds the last main file + var main = (isDep && lastMain) || findMainFile(context); + if (main) runBuild(context, platform!, main); + }, 300); +} + +/** The setting, else the active editor's file, else the last main file. */ +function findMainFile(context: vscode.ExtensionContext): vscode.Uri | undefined { + var setting = config().get('mainFile'); + var folder = vscode.workspace.workspaceFolders?.[0]; + if (setting) { + return path.isAbsolute(setting) || !folder ? vscode.Uri.file(setting) : vscode.Uri.joinPath(folder.uri, setting); + } + var editor = vscode.window.activeTextEditor; + if (editor && loadBuildCore(context).isSourceFile(editor.document.fileName)) { + return editor.document.uri; + } + return lastMain; +} + +async function runBuild(context: vscode.ExtensionContext, platform: string, main: vscode.Uri) { + var core = loadBuildCore(context); + // paths are relative to the main file's directory, like the IDE's project root + var rootUri = vscode.Uri.joinPath(main, '..'); + var toUri = (rel: string) => vscode.Uri.joinPath(rootUri, rel); + var read = async (rel: string): Promise => { + var uri = toUri(rel); + // unsaved editor contents win over disk + var doc = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString()); + if (doc) return new TextEncoder().encode(doc.getText()); + try { + return await vscode.workspace.fs.readFile(uri); + } catch (e) { + return null; + } + }; + var mainPath = path.posix.basename(main.path); + var mainData = await read(mainPath); + if (mainData == null) { + vscode.window.showErrorMessage(`8bitworkshop: cannot read ${main.fsPath}`); + return; + } + var t0 = Date.now(); + status.text = '$(sync~spin) ' + platform; + var result: BuildCore.BuildOutcome; + try { + result = await builder.build({ + platform, + mainPath, + mainText: new TextDecoder().decode(mainData), + files: new core.ProjectFileProvider(read, builder.rootDir, platform), + tool: config().get('tool') || undefined, + }); + } catch (e) { + output.appendLine(`Build crashed: ${e && e.stack || e}`); + output.show(true); + return; + } finally { + updateStatus(); + } + lastMain = main; + lastPaths = new Set(result.paths.map(p => toUri(p).fsPath)); + if (result.unchanged) return result; + lastBuild = result; + showDiagnostics(result, toUri); + var elapsed = Date.now() - t0; + if (result.success) { + var size = result.output?.length ?? 0; + output.appendLine(`${mainPath}: built with ${result.tool} for ${platform}, ${size} bytes (${elapsed} ms)`); + } else { + output.appendLine(`${mainPath}: ${result.diagnostics.length} error(s) from ${result.tool} (${elapsed} ms)`); + for (var d of result.diagnostics) output.appendLine(` ${d.path}:${d.line}: ${d.msg}`); + } + return result; +} + +function showDiagnostics(result: BuildCore.BuildOutcome, toUri: (rel: string) => vscode.Uri) { + diagnostics.clear(); + var byFile = new Map(); + for (var d of result.diagnostics) { + var uri = toUri(d.path); + var key = uri.toString(); + if (!byFile.has(key)) byFile.set(key, { uri, diags: [] }); + var line = Math.max(0, d.line - 1); + var range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER); + var diag = new vscode.Diagnostic(range, d.msg, vscode.DiagnosticSeverity.Error); + diag.source = result.tool; + byFile.get(key)!.diags.push(diag); + } + for (var { uri, diags } of byFile.values()) diagnostics.set(uri, diags); +} + +/** The most recent successful or failed build, for the emulator (milestone 2). */ +export function getLastBuild() { + return lastBuild; +} diff --git a/extension/test/buildcore.test.ts b/extension/test/buildcore.test.ts new file mode 100644 index 00000000..093a70f7 --- /dev/null +++ b/extension/test/buildcore.test.ts @@ -0,0 +1,73 @@ + +import * as assert from 'assert'; +import * as path from 'path'; +import { Builder, ProjectFileProvider, findRootDir, isSourceFile } from '../src/buildcore'; + +// Mocha runs from extension/, not the repo root, so this also checks that +// the toolchain assets load from the configured root rather than the cwd. +const ROOT = findRootDir(__dirname); + +function project(files: { [path: string]: string }) { + var enc = new TextEncoder(); + return async (rel: string) => rel in files ? enc.encode(files[rel]) : null; +} + +describe('extension buildcore', function () { + var builder = new Builder(ROOT); + + it('finds the repo as the asset root', function () { + assert.equal(ROOT, path.resolve(__dirname, '../../..')); + }); + + it('recognizes source files', function () { + assert.ok(isSourceFile('main.c')); + assert.ok(isSourceFile('game.dasm')); + assert.ok(!isSourceFile('settings.json')); + }); + + it('builds a C file with a local header and a preset header', async function () { + var files = project({ + 'main.c': '#include "neslib.h"\n#include "util.h"\nvoid main(void) { ppu_on_all(); while (1) { x = 1; } }\n', + 'util.h': 'static int x;\n', + }); + var mainText = new TextDecoder().decode(await files('main.c')); + var r = await builder.build({ + platform: 'nes', mainPath: 'main.c', mainText, + files: new ProjectFileProvider(files, ROOT, 'nes'), + }); + assert.deepEqual(r.diagnostics, []); + assert.ok(r.success); + assert.equal(r.tool, 'cc65'); + assert.ok(r.output.length > 0); + assert.ok(r.paths.indexOf('util.h') >= 0); + assert.ok(r.listings); + }); + + it('reports errors against the file that has them', async function () { + var files = project({ + 'main.c': '#include "util.h"\nvoid main(void) { }\n', + 'util.h': 'int x;\nthis is not C;\n', + }); + var mainText = new TextDecoder().decode(await files('main.c')); + var r = await builder.build({ + platform: 'nes', mainPath: 'main.c', mainText, + files: new ProjectFileProvider(files, ROOT, 'nes'), + }); + assert.ok(!r.success); + assert.ok(r.diagnostics.length > 0); + assert.ok(r.diagnostics.every(d => d.path === 'util.h'), JSON.stringify(r.diagnostics)); + assert.equal(r.diagnostics[0].line, 2); + }); + + it('reports assembler errors in the main file', async function () { + var src = '\tprocessor 6502\n\torg $f000\nStart\n\tlda #1\n\tbogus\n'; + var r = await builder.build({ + platform: 'vcs', mainPath: 'game.a', mainText: src, + files: new ProjectFileProvider(project({ 'game.a': src }), ROOT, 'vcs'), + }); + assert.ok(!r.success); + assert.equal(r.tool, 'dasm'); + assert.equal(r.diagnostics[0].path, 'game.a'); + assert.equal(r.diagnostics[0].line, 5); + }); +}); diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 00000000..30287c42 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + // Type checking only; scripts/build.mjs bundles with esbuild. + "extends": "../tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["node", "mocha", "vscode"], + "typeRoots": ["./node_modules/@types", "../node_modules/@types"] + } +}