[VSCode extension] Add [eval with bun] code lens on untitled, unsaved scratch JavaScript files (#14983)

This commit is contained in:
Jarred Sumner
2024-11-07 12:30:38 -08:00
committed by GitHub
parent a116b2281e
commit 1e932ff38b
4 changed files with 331 additions and 36 deletions

View File

@@ -118,6 +118,8 @@ type LaunchRequest = DAP.LaunchRequest & {
stopOnEntry?: boolean;
noDebug?: boolean;
watchMode?: boolean | "hot";
__skipValidation?: boolean;
stdin?: string;
};
type AttachRequest = DAP.AttachRequest & {
@@ -211,6 +213,24 @@ const debugSilentEvents = new Set(["Adapter.event", "Inspector.event"]);
let threadId = 1;
// Add these helper functions at the top level
function normalizeSourcePath(sourcePath: string, untitledDocPath?: string, bunEvalPath?: string): string {
if (!sourcePath) return sourcePath;
// Handle eval source paths
if (sourcePath === bunEvalPath) {
return bunEvalPath!;
}
// Handle untitled documents
if (sourcePath === untitledDocPath) {
return bunEvalPath!;
}
// Handle normal file paths
return path.normalize(sourcePath);
}
export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements IDebugAdapter {
#threadId: number;
#inspector: WebSocketInspector;
@@ -229,8 +249,10 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
#variables: Map<number, Variable>;
#initialized?: InitializeRequest;
#options?: DebuggerOptions;
#untitledDocPath?: string;
#bunEvalPath?: string;
constructor(url?: string | URL) {
constructor(url?: string | URL, untitledDocPath?: string, bunEvalPath?: string) {
super();
this.#threadId = threadId++;
this.#inspector = new WebSocketInspector(url);
@@ -252,6 +274,8 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
this.#targets = new Map();
this.#variableId = 1;
this.#variables = new Map();
this.#untitledDocPath = untitledDocPath;
this.#bunEvalPath = bunEvalPath;
}
/**
@@ -474,19 +498,25 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
strictEnv = false,
watchMode = false,
stopOnEntry = false,
__skipValidation = false,
stdin,
} = request;
if (!program) {
throw new Error("No program specified. Did you set the 'program' property in your launch.json?");
if (!__skipValidation && !program) {
throw new Error("No program specified");
}
if (!isJavaScript(program)) {
throw new Error("Program must be a JavaScript or TypeScript file.");
const processArgs = [...runtimeArgs];
if (program === "-" && stdin) {
processArgs.push("--eval", stdin);
} else if (program) {
processArgs.push(program);
}
const processArgs = [...runtimeArgs, program, ...args];
processArgs.push(...args);
if (isTestJavaScript(program) && !runtimeArgs.includes("test")) {
if (program && isTestJavaScript(program) && !runtimeArgs.includes("test")) {
processArgs.unshift("test");
}
@@ -1073,15 +1103,21 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
#getBreakpointByLocation(source: Source, location: DAP.SourceBreakpoint): Breakpoint | undefined {
console.log("getBreakpointByLocation", {
source: sourceToId(source),
location,
ids: this.#getBreakpoints(sourceToId(source)).map(({ id }) => id),
breakpointIds: this.#getBreakpoints(sourceToId(source)).map(({ breakpointId }) => breakpointId),
lines: this.#getBreakpoints(sourceToId(source)).map(({ line }) => line),
columns: this.#getBreakpoints(sourceToId(source)).map(({ column }) => column),
});
const sourceId = sourceToId(source);
if (isDebug) {
console.log("getBreakpointByLocation", {
source: sourceToId(source),
location,
ids: this.#getBreakpoints(sourceToId(source)).map(({ id }) => id),
breakpointIds: this.#getBreakpoints(sourceToId(source)).map(({ breakpointId }) => breakpointId),
lines: this.#getBreakpoints(sourceToId(source)).map(({ line }) => line),
columns: this.#getBreakpoints(sourceToId(source)).map(({ column }) => column),
});
}
let sourceId = sourceToId(source);
const untitledDocPath = this.#untitledDocPath;
if (sourceId === untitledDocPath && this.#bunEvalPath) {
sourceId = this.#bunEvalPath;
}
const [breakpoint] = this.#getBreakpoints(sourceId).filter(
({ source, request }) => source && sourceToId(source) === sourceId && request?.line === location.line,
);
@@ -1089,7 +1125,18 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
#getBreakpoints(sourceId: string | number): Breakpoint[] {
return [...this.#breakpoints.values()].flat().filter(({ source }) => source && sourceToId(source) === sourceId);
let output = [];
let all = this.#breakpoints;
for (const breakpoints of all.values()) {
for (const breakpoint of breakpoints) {
const source = breakpoint.source;
if (source && sourceToId(source) === sourceId) {
output.push(breakpoint);
}
}
}
return output;
}
#getFutureBreakpoints(breakpointId: string): FutureBreakpoint[] {
@@ -1632,7 +1679,12 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
}
#addSource(source: Source): Source {
const { sourceId, scriptId, path, sourceReference } = source;
let { sourceId, scriptId, path } = source;
// Normalize the source path
if (path) {
path = source.path = normalizeSourcePath(path, this.#untitledDocPath, this.#bunEvalPath);
}
const oldSource = this.#getSourceIfPresent(sourceId);
if (oldSource) {
@@ -1704,10 +1756,9 @@ export class DebugAdapter extends EventEmitter<DebugAdapterEventMap> implements
return source;
}
// If the source does not have a path or is a builtin module,
// it cannot be retrieved from the file system.
if (typeof sourceId === "number" || !path.isAbsolute(sourceId)) {
throw new Error(`Source not found: ${sourceId}`);
// Normalize the source path before lookup
if (typeof sourceId === "string") {
sourceId = normalizeSourcePath(sourceId, this.#untitledDocPath, this.#bunEvalPath);
}
// If the source is not present, it may not have been loaded yet.

View File

@@ -114,6 +114,14 @@
"category": "Bun",
"enablement": "!inDebugMode",
"icon": "$(debug-alt)"
},
{
"command": "extension.bun.runUnsavedCode",
"title": "Run Unsaved Code with Bun",
"shortTitle": "Run with Bun",
"category": "Bun",
"enablement": "!inDebugMode && resourceLangId =~ /^(javascript|typescript|javascriptreact|typescriptreact)$/ && !isInDiffEditor && resourceScheme == 'untitled'",
"icon": "$(play-circle)"
}
],
"menus": {
@@ -138,6 +146,20 @@
"command": "extension.bun.debugFile",
"when": "resourceLangId == javascript || resourceLangId == javascriptreact || resourceLangId == typescript || resourceLangId == typescriptreact"
}
],
"editor/title": [
{
"command": "extension.bun.runUnsavedCode",
"when": "resourceLangId =~ /^(javascript|typescript|javascriptreact|typescriptreact)$/ && !isInDiffEditor && resourceScheme == 'untitled'",
"group": "navigation"
}
],
"editor/context": [
{
"command": "extension.bun.runUnsavedCode",
"when": "resourceLangId =~ /^(javascript|typescript|javascriptreact|typescriptreact)$/ && !isInDiffEditor && resourceScheme == 'untitled'",
"group": "1_run"
}
]
},
"breakpoints": [

View File

@@ -1,14 +1,52 @@
import * as vscode from "vscode";
import { registerDebugger } from "./features/debug";
import { registerDebugger, debugCommand } from "./features/debug";
import { registerBunlockEditor } from "./features/lockfile";
import { registerPackageJsonProviders } from "./features/tasks/package.json";
import { registerTaskProvider } from "./features/tasks/tasks";
async function runUnsavedCode() {
const editor = vscode.window.activeTextEditor;
if (!editor || !editor.document.isUntitled) {
return;
}
const document = editor.document;
if (!["javascript", "typescript", "javascriptreact", "typescriptreact"].includes(document.languageId)) {
return;
}
const code = document.getText();
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
// Get the actual untitled document name
const untitledName = `untitled:${document.uri.path}`;
// Create a temporary debug session without saving
await vscode.debug.startDebugging(
undefined,
{
type: "bun",
name: "Run Unsaved Code",
request: "launch",
program: "-", // Special flag to indicate stdin input
__code: code, // Pass the code through configuration
__untitledName: untitledName, // Pass the untitled document name
cwd, // Pass the current working directory
},
{
suppressSaveBeforeStart: true, // This prevents the save dialog
},
);
}
export function activate(context: vscode.ExtensionContext) {
registerBunlockEditor(context);
registerDebugger(context);
registerTaskProvider(context);
registerPackageJsonProviders(context);
// Only register for text editors
context.subscriptions.push(vscode.commands.registerTextEditorCommand("extension.bun.runUnsavedCode", runUnsavedCode));
}
export function deactivate() {}

View File

@@ -1,8 +1,9 @@
import { DebugSession } from "@vscode/debugadapter";
import { DebugSession, OutputEvent } from "@vscode/debugadapter";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as vscode from "vscode";
import {
DAP,
type DAP,
DebugAdapter,
getAvailablePort,
getRandomId,
@@ -45,6 +46,10 @@ const adapters = new Map<string, FileDebugSession>();
export function registerDebugger(context: vscode.ExtensionContext, factory?: vscode.DebugAdapterDescriptorFactory) {
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
["javascript", "typescript", "javascriptreact", "typescriptreact"],
new BunCodeLensProvider(),
),
vscode.commands.registerCommand("extension.bun.runFile", runFileCommand),
vscode.commands.registerCommand("extension.bun.debugFile", debugFileCommand),
vscode.debug.registerDebugConfigurationProvider(
@@ -144,7 +149,15 @@ class DebugConfigurationProvider implements vscode.DebugConfigurationProvider {
target = DEBUG_CONFIGURATION;
}
// If the configuration is missing a default property, copy it from the template.
if (config.program === "-" && config.__code) {
const code = config.__code;
delete config.__code;
config.stdin = code;
config.program = "-";
config.__skipValidation = true;
}
for (const [key, value] of Object.entries(target)) {
if (config[key] === undefined) {
config[key] = value;
@@ -165,7 +178,7 @@ class InlineDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory
session: vscode.DebugSession,
): Promise<vscode.ProviderResult<vscode.DebugAdapterDescriptor>> {
const { configuration } = session;
const { request, url } = configuration;
const { request, url, __untitledName } = configuration;
if (request === "attach") {
for (const [adapterUrl, adapter] of adapters) {
@@ -175,32 +188,105 @@ class InlineDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory
}
}
const adapter = new FileDebugSession(session.id);
const adapter = new FileDebugSession(session.id, __untitledName);
await adapter.initialize();
return new vscode.DebugAdapterInlineImplementation(adapter);
}
}
interface DebugProtocolResponse extends DAP.Response {
body?: {
source?: {
path?: string;
};
breakpoints?: Array<{
source?: {
path?: string;
};
verified?: boolean;
}>;
};
}
interface DebugProtocolEvent extends DAP.Event {
body?: {
source?: {
path?: string;
};
};
}
interface RuntimeConsoleAPICalledEvent {
type: string;
args: Array<{
type: string;
value: any;
}>;
}
interface RuntimeExceptionThrownEvent {
exceptionDetails: {
text: string;
exception?: {
description?: string;
};
};
}
class FileDebugSession extends DebugSession {
adapter: DebugAdapter;
sessionId?: string;
untitledDocPath?: string;
bunEvalPath?: string;
constructor(sessionId?: string) {
constructor(sessionId?: string, untitledDocPath?: string) {
super();
this.sessionId = sessionId;
this.untitledDocPath = untitledDocPath;
if (untitledDocPath) {
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri?.fsPath ?? process.cwd();
this.bunEvalPath = join(cwd, "[eval]");
}
}
async initialize() {
const uniqueId = this.sessionId ?? Math.random().toString(36).slice(2);
let url;
if (process.platform === "win32") {
url = `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}`;
const url =
process.platform === "win32"
? `ws://127.0.0.1:${await getAvailablePort()}/${getRandomId()}`
: `ws+unix://${tmpdir()}/${uniqueId}.sock`;
const { untitledDocPath, bunEvalPath } = this;
this.adapter = new DebugAdapter(url, untitledDocPath, bunEvalPath);
if (untitledDocPath) {
this.adapter.on("Adapter.response", (response: DebugProtocolResponse) => {
if (response.body?.source?.path === bunEvalPath) {
response.body.source.path = untitledDocPath;
}
if (Array.isArray(response.body?.breakpoints)) {
for (const bp of response.body.breakpoints) {
if (bp.source?.path === bunEvalPath) {
bp.source.path = untitledDocPath;
bp.verified = true;
}
}
}
this.sendResponse(response);
});
this.adapter.on("Adapter.event", (event: DebugProtocolEvent) => {
if (event.body?.source?.path === bunEvalPath) {
event.body.source.path = untitledDocPath;
}
this.sendEvent(event);
});
} else {
url = `ws+unix://${tmpdir()}/${uniqueId}.sock`;
this.adapter.on("Adapter.response", response => this.sendResponse(response));
this.adapter.on("Adapter.event", event => this.sendEvent(event));
}
this.adapter = new DebugAdapter(url);
this.adapter.on("Adapter.response", response => this.sendResponse(response));
this.adapter.on("Adapter.event", event => this.sendEvent(event));
this.adapter.on("Adapter.reverseRequest", ({ command, arguments: args }) =>
this.sendRequest(command, args, 5000, () => {}),
);
@@ -212,6 +298,15 @@ class FileDebugSession extends DebugSession {
const { type } = message;
if (type === "request") {
const { untitledDocPath, bunEvalPath } = this;
const { command } = message;
if (untitledDocPath && (command === "setBreakpoints" || command === "breakpointLocations")) {
const args = message.arguments as any;
if (args.source?.path === untitledDocPath) {
args.source.path = bunEvalPath;
}
}
this.adapter.emit("Adapter.request", message);
} else {
throw new Error(`Not supported: ${type}`);
@@ -273,3 +368,92 @@ function getRuntime(scope?: vscode.ConfigurationScope): string {
function getConfig<T>(path: string, scope?: vscode.ConfigurationScope) {
return vscode.workspace.getConfiguration("bun", scope).get<T>(path);
}
export async function runUnsavedCode() {
const editor = vscode.window.activeTextEditor;
if (!editor || !editor.document.isUntitled) return;
const code = editor.document.getText();
const startTime = performance.now();
try {
// Start debugging
await vscode.debug.startDebugging(undefined, {
...DEBUG_CONFIGURATION,
program: "-",
__code: code,
__untitledName: editor.document.uri.toString(),
console: "debugConsole",
internalConsoleOptions: "openOnSessionStart",
});
// Find our debug session instance
const debugSession = Array.from(adapters.values()).find(
adapter => adapter.sessionId === vscode.debug.activeDebugSession?.id,
);
if (debugSession) {
// Wait for both the inspector to connect AND the adapter to be initialized
await new Promise<void>(resolve => {
let inspectorConnected = false;
let adapterInitialized = false;
const checkDone = () => {
if (inspectorConnected && adapterInitialized) {
resolve();
}
};
debugSession.adapter.once("Inspector.connected", () => {
inspectorConnected = true;
checkDone();
});
debugSession.adapter.once("Adapter.initialized", () => {
adapterInitialized = true;
checkDone();
});
});
// Now wait for debug session to complete
await new Promise<void>(resolve => {
const disposable = vscode.debug.onDidTerminateDebugSession(() => {
const duration = (performance.now() - startTime).toFixed(1);
debugSession.sendEvent(new OutputEvent(`✓ Code execution completed in ${duration}ms\n`));
disposable.dispose();
resolve();
});
});
}
} catch (err) {
if (vscode.debug.activeDebugSession) {
const duration = (performance.now() - startTime).toFixed(1);
const errorSession = adapters.get(vscode.debug.activeDebugSession.id);
errorSession?.sendEvent(
new OutputEvent(`✕ Error after ${duration}ms: ${err instanceof Error ? err.message : String(err)}\n`),
);
}
}
}
const languageIds = ["javascript", "typescript", "javascriptreact", "typescriptreact"];
class BunCodeLensProvider implements vscode.CodeLensProvider {
async provideCodeLenses(document: vscode.TextDocument): Promise<vscode.CodeLens[]> {
if (!document.isUntitled || document.isClosed || document.lineCount === 0) return [];
if (!languageIds.includes(document.languageId)) {
return [];
}
// Create a range at position 0,0 with zero width
const range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
return [
new vscode.CodeLens(range, {
title: "eval with bun",
command: "extension.bun.runUnsavedCode",
tooltip: "Run this unsaved, scratch file with Bun",
}),
];
}
}