bimba-cli
Version:
The CLI tool to run Imba projects under Bun
267 lines (220 loc) • 9.1 kB
JavaScript
import { spawn } from 'child_process';
import { createRequire } from 'module';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { theme } from './utils.js';
const require = createRequire(import.meta.url);
const SKIP_DIRS = new Set([
'.bimba',
'.cache',
'.git',
'build',
'dist',
'node_modules',
'public',
]);
function canResolve(request, from) {
try {
return require.resolve(request, { paths: [from] });
}
catch {
return null;
}
}
function hasPackage(root, name) {
return fs.existsSync(path.join(root, name, 'package.json'));
}
function findTypeScript(cwd) {
const tsserver = canResolve('typescript/lib/tsserver.js', cwd);
if (tsserver) return tsserver;
throw new Error('Could not find TypeScript. Install it in this project: bun add -d typescript');
}
function findPluginProbe(cwd) {
const localProbe = path.join(cwd, 'node_modules');
if (hasPackage(localProbe, 'typescript-imba-plugin')) return localProbe;
const extensionRoots = [
path.join(os.homedir(), '.vscode', 'extensions'),
path.join(os.homedir(), '.cursor', 'extensions'),
path.join(os.homedir(), '.windsurf', 'extensions'),
path.join(os.homedir(), '.kiro', 'extensions'),
];
for (const root of extensionRoots) {
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root)) {
const probe = path.join(root, entry, 'node_modules');
if (hasPackage(probe, 'typescript-imba-plugin')) return probe;
}
}
throw new Error('Could not find typescript-imba-plugin. Install the Imba VSCode extension or add the plugin to node_modules.');
}
function getScanRoot(entrypoint, cwd) {
if (!entrypoint) {
const src = path.join(cwd, 'src');
return fs.existsSync(src) ? src : cwd;
}
const resolved = path.resolve(cwd, entrypoint);
if (!fs.existsSync(resolved)) {
throw new Error(`The specified typecheck path does not exist: ${entrypoint}`);
}
const stat = fs.statSync(resolved);
return stat.isDirectory() ? resolved : path.dirname(resolved);
}
function collectImbaFiles(root) {
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name));
}
else if (entry.isFile() && entry.name.endsWith('.imba')) {
files.push(path.join(dir, entry.name));
}
}
}
walk(root);
files.sort();
return files;
}
function parseMessages(buffer, onMessage) {
while (true) {
const text = buffer.toString('utf8');
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd < 0) return buffer;
const match = /Content-Length: (\d+)/i.exec(text.slice(0, headerEnd));
if (!match) return buffer;
const length = Number(match[1]);
const bodyStart = Buffer.byteLength(text.slice(0, headerEnd + 4));
if (buffer.length < bodyStart + length) return buffer;
const body = buffer.slice(bodyStart, bodyStart + length).toString('utf8');
buffer = buffer.slice(bodyStart + length);
try {
onMessage(JSON.parse(body));
}
catch {}
}
}
function flattenMessage(text) {
if (typeof text == 'string') return text;
if (!text) return '';
if (text.messageText) {
const next = Array.isArray(text.next) ? text.next.map(flattenMessage) : [];
return [flattenMessage(text.messageText), ...next].filter(Boolean).join(' ');
}
return String(text);
}
function uniqueDiagnostics(diagnostics) {
const unique = Array.from(new Map(diagnostics.map(item => [item.key, item])).values());
unique.sort((a, b) => {
return a.file.localeCompare(b.file)
|| (a.start?.line || 0) - (b.start?.line || 0)
|| (a.start?.offset || 0) - (b.start?.offset || 0)
|| String(a.code).localeCompare(String(b.code));
});
return unique;
}
function printDiagnostics(cwd, diagnostics) {
for (const item of diagnostics) {
const rel = path.relative(cwd, item.file);
const line = item.start?.line || 0;
const offset = item.start?.offset || 0;
const code = item.code ? `TS${item.code}` : 'TS';
const category = item.category || 'error';
const text = flattenMessage(item.text);
console.log(`${theme.filedir(rel)}:${line}:${offset} ${theme.action(item.kind)} ${theme.failure(` ${code} `)} ${category}: ${text}`);
}
}
function send(server, seq, command, args) {
server.stdin.write(JSON.stringify({ seq: seq.value++, type: 'request', command, arguments: args }) + '\n');
}
export async function checkImbaTypes(entrypoint, options = {}) {
const cwd = options.cwd || process.cwd();
const timeout = Number(options.timeout || process.env.BIMBA_TYPECHECK_TIMEOUT || process.env.IMBA_TS_CHECK_TIMEOUT || 30000);
const scanRoot = getScanRoot(entrypoint, cwd);
const files = collectImbaFiles(scanRoot);
if (!files.length) {
console.log(theme.success('Success') + ` No Imba files found in ${theme.filedir(path.relative(cwd, scanRoot) || '.')}`);
return true;
}
const tsserver = findTypeScript(cwd);
const pluginProbe = findPluginProbe(cwd);
const runner = process.env.BIMBA_NODE || process.env.NODE || 'node';
console.log(theme.folder('──────────────────────────────────────────────────────────────────────'));
console.log(theme.start(`Start checking TypeScript diagnostics for ${theme.count(files.length)} Imba file${files.length > 1 ? 's' : ''}`));
return await new Promise((resolve) => {
let settled = false;
let buffer = Buffer.alloc(0);
const seq = { value: 1 };
const diagnostics = [];
let geterrSeq = null;
const server = spawn(runner, [
tsserver,
'--globalPlugins',
'typescript-imba-plugin',
'--pluginProbeLocations',
pluginProbe,
], { cwd });
function finish(success) {
if (settled) return;
settled = true;
clearTimeout(timer);
server.kill();
resolve(success);
}
function finishWithDiagnostics() {
const unique = uniqueDiagnostics(diagnostics);
if (!unique.length) {
console.log(theme.success('Success') + ' No Imba TypeScript diagnostics');
finish(true);
return;
}
printDiagnostics(cwd, unique);
console.log(theme.failure(' Failure ') + ` TypeScript found ${theme.count(unique.length)} diagnostic${unique.length > 1 ? 's' : ''}`);
finish(false);
}
const timer = setTimeout(() => {
console.log(theme.failure(' Failure ') + ` Timed out waiting for TypeScript diagnostics after ${theme.time(timeout)} ms`);
finish(false);
}, timeout);
server.on('error', (error) => {
console.log(theme.failure(' Failure ') + ` Could not start ${runner}: ${error.message}`);
finish(false);
});
server.stderr.on('data', chunk => process.stderr.write(chunk));
server.stdout.on('data', chunk => {
buffer = Buffer.concat([buffer, chunk]);
buffer = parseMessages(buffer, msg => {
if (msg.type != 'event') return;
if (msg.event == 'requestCompleted' && msg.body?.request_seq == geterrSeq) {
finishWithDiagnostics();
return;
}
if (!/Diag$/.test(msg.event)) return;
if (!msg.body?.diagnostics?.length) return;
for (const diagnostic of msg.body.diagnostics) {
const key = [
msg.event,
msg.body.file,
diagnostic.start?.line,
diagnostic.start?.offset,
diagnostic.code,
flattenMessage(diagnostic.text),
].join('\0');
diagnostics.push({
key,
kind: msg.event,
file: msg.body.file,
...diagnostic,
});
}
});
});
setTimeout(() => {
if (settled) return;
send(server, seq, 'configure', { preferences: {}, hostInfo: 'bimba-typecheck' });
for (const file of files) send(server, seq, 'open', { file, projectRootPath: cwd });
geterrSeq = seq.value;
send(server, seq, 'geterr', { files, delay: 0 });
}, 100);
});
}