diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index 677db5e41c..8532aa2afe 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -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 implements IDebugAdapter { #threadId: number; #inspector: WebSocketInspector; @@ -229,8 +249,10 @@ export class DebugAdapter extends EventEmitter implements #variables: Map; #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 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 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 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 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 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 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. diff --git a/packages/bun-vscode/package.json b/packages/bun-vscode/package.json index 23ace8a229..f48dfc6db8 100644 --- a/packages/bun-vscode/package.json +++ b/packages/bun-vscode/package.json @@ -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": [ diff --git a/packages/bun-vscode/src/extension.ts b/packages/bun-vscode/src/extension.ts index fc1abe4240..69017a65de 100644 --- a/packages/bun-vscode/src/extension.ts +++ b/packages/bun-vscode/src/extension.ts @@ -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() {} diff --git a/packages/bun-vscode/src/features/debug.ts b/packages/bun-vscode/src/features/debug.ts index 2dd68c8695..538d907a3d 100644 --- a/packages/bun-vscode/src/features/debug.ts +++ b/packages/bun-vscode/src/features/debug.ts @@ -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(); 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> { 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(path: string, scope?: vscode.ConfigurationScope) { return vscode.workspace.getConfiguration("bun", scope).get(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(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(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 { + 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", + }), + ]; + } +}