diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b9b4d..009218c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] ### Added -- **Claude Code auto-registration** - Claude Code is now offered in the agent selection popup and configured via `~/.claude.json`'s user-scope `mcpServers` field. Claude Desktop connects via its Custom Connector UI instead of a static config file; the README's manual configuration section covers both. +- Breakpoint, logpoint, and removal tools now support VS Code virtual-document URIs, including Business Central sources. An optional selects the correct workspace when multiple editor windows are open. +- **Claude Code auto-registration** - Claude Code is now offered in the agent selection popup and configured via 's user-scope field. Claude Desktop connects via its Custom Connector UI instead of a static config file; the README's manual configuration section covers both. ## [2.3.5] - 2026-09-09 diff --git a/README.md b/README.md index bd752d2..e6bbdc7 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ DebugMCP is an MCP server that gives AI coding agents full control over the VS C | **continue_execution** | Continue until next breakpoint | None | | **pause_execution** | Interrupt a freely-running program and stop at its current location (no breakpoint needed) | None | | **restart_debugging** | Restart the current debug session | None | -| **add_breakpoint** | Add a breakpoint at a specific line (optionally conditional) | `fileFullPath` (required)
`line` (required, 1-based)
`condition` (optional) | -| **add_logpoint** | Add a logpoint that logs a message (instead of pausing) when a line is reached | `fileFullPath` (required)
`line` (required, 1-based)
`logMessage` (required, `{expr}` interpolated)
`condition` (optional) | -| **remove_breakpoint** | Remove a breakpoint from a specific line | `fileFullPath` (required)
`line` (required) | +| **add_breakpoint** | Add a breakpoint at a specific line (optionally conditional) | `fileFullPath` (required; path or virtual URI)
`workingDirectory` (optional; identifies the window for virtual URIs)
`line` (required, 1-based)
`condition` (optional) | +| **add_logpoint** | Add a logpoint that logs a message (instead of pausing) when a line is reached | `fileFullPath` (required; path or virtual URI)
`workingDirectory` (optional; identifies the window for virtual URIs)
`line` (required, 1-based)
`logMessage` (required, `{expr}` interpolated)
`condition` (optional) | +| **remove_breakpoint** | Remove a breakpoint from a specific line | `fileFullPath` (required; path or virtual URI)
`workingDirectory` (optional; identifies the window for virtual URIs)
`line` (required) | | **clear_all_breakpoints** | Remove all breakpoints at once | None | | **list_breakpoints** | List all active breakpoints | None | | **list_variable_names** | List names and types of variables in scope, without reading any values | `scope` (optional: 'local', 'global', 'all') | @@ -231,6 +231,16 @@ DebugMCP supports debugging for the following languages with their respective VS | **PHP** | [PHP Debug](https://marketplace.visualstudio.com/items?itemName=xdebug.php-debug) | `.php` | ✅ Fully Supported | | **Ruby** | [Ruby](https://marketplace.visualstudio.com/items?itemName=Shopify.ruby-lsp) | `.rb` | ✅ Fully Supported | | **C#/.NET** | [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) | `.cs`, `.csproj` | ✅ Fully Supported | +| **AL (Business Central)** | [AL Language](https://marketplace.visualstudio.com/items?itemName=ms-dynamics-smb.al) | `.al`, virtual `.dal` | ✅ Supported with an AL launch configuration | + +### Virtual source documents + +Breakpoint tools accept native filesystem paths and VS Code virtual-document URIs. This +supports generated or downloaded sources that a language extension exposes without a local +file, including Business Central dependency objects such as +`al-preview://AlLang/.../Table/18/Customer.dal`. When multiple editor windows are open, +pass `workingDirectory` with the virtual URI so DebugMCP routes the operation to the correct +workspace. ## Configuration @@ -593,3 +603,4 @@ If DebugMCP has helped you debug faster, please consider giving it a star on Git ## License MIT License - See [LICENSE](LICENSE.txt) for details +This extension was created by **Oz Zafar**, **Ori Bar-Ilan** and **Karin Brisker**. diff --git a/docs/architecture/debugMCPServer.md b/docs/architecture/debugMCPServer.md index 092408a..eeafe80 100644 --- a/docs/architecture/debugMCPServer.md +++ b/docs/architecture/debugMCPServer.md @@ -66,6 +66,8 @@ extension. To avoid debugging the wrong workspace when several windows are open: operation to that window's `ControlServer`. The target is cached per session so hint-less follow-ups (step/continue/inspect) reach the same window. If the router window closes, a worker window takes over the port on retry. +- Breakpoint tools also accept virtual source URIs. A sole registered window is unambiguous; + with multiple windows, callers provide the optional `workingDirectory` routing hint. `DebugMCPServer` builds one handler **per MCP session** via a handler factory, which is what lets concurrent agent sessions drive debuggers in different repos simultaneously. @@ -133,7 +135,7 @@ error wins: | `continue_execution` | Continue to next breakpoint | | `pause_execution` | Interrupt a running program (no breakpoint needed) | | `restart_debugging` | Restart session | -| `add/remove_breakpoint` | Breakpoint management | +| `add/remove_breakpoint` | Breakpoint management for local paths and virtual source URIs | | `clear_all_breakpoints` | Remove all breakpoints | | `list_breakpoints` | List active breakpoints | | `get_variables_values` | Read the values of specifically named variables | @@ -143,4 +145,4 @@ error wins: ## Configuration - `debugmcp.serverPort`: Port number (default: 3001) -- `debugmcp.timeoutInSeconds`: Operation timeout (default: 180) \ No newline at end of file +- `debugmcp.timeoutInSeconds`: Operation timeout (default: 180) diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 3ef2b77..bcba83c 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -13,6 +13,7 @@ Debugging is inherently asynchronous - when you step over a line, the debugger t ## Responsibility - Orchestrate debugging operations (start, stop, step, breakpoints) +- Preserve language-extension virtual source URIs when opening documents and setting breakpoints - Detect when debugger state has meaningfully changed after commands - Format debug state into human/AI-readable responses - Recursively format explicitly requested structs and arrays @@ -64,6 +65,13 @@ A state change is considered meaningful when any of these change: - Frame name (function/method) - Frame ID +### Virtual source documents + +Breakpoint and logpoint locations may be native paths or VS Code virtual-document URIs. +`src/utils/sourceUri.ts` keeps custom schemes intact instead of converting them into malformed +`file:` URIs. This is required for language-extension sources such as Business Central `.dal` +documents served through the `al-preview:` scheme. + ### Root Cause Analysis When debugging stops, the handler prompts AI agents to consider whether they found the root cause or just a symptom, encouraging deeper investigation. diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index a6da21d..83648ec 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -258,33 +258,36 @@ export class DebugMCPServer { server.registerTool('add_breakpoint', { description: 'Set a breakpoint to pause execution at a critical line of code. Breakpoints let you inspect variables and control flow at exact moments.', inputSchema: { - fileFullPath: z.string().describe('Full path to the file'), + fileFullPath: z.string().describe('Full path or VS Code virtual-document URI of the source file'), + workingDirectory: z.string().optional().describe('Workspace directory used to select the correct VS Code window. Required for a virtual-document URI when multiple windows are open.'), line: z.number().int().describe('Line number (1-based) where the breakpoint should be set'), condition: z.string().optional().describe('Optional condition expression. When provided, execution only pauses if this expression evaluates to true at the breakpoint location.'), }, - }, async (args: { fileFullPath: string; line: number; condition?: string }) => + }, async (args: { fileFullPath: string; workingDirectory?: string; line: number; condition?: string }) => this.runTool('add_breakpoint', () => debuggingHandler.handleAddBreakpoint(args))); // Add logpoint tool server.registerTool('add_logpoint', { description: 'Add a logpoint: a breakpoint that logs a message instead of pausing execution. Ideal for tracing values across many iterations or hot paths without stopping, or where a hard pause would distort timing. Embed expressions in curly braces to interpolate runtime values, e.g. "user id={user.id}".', inputSchema: { - fileFullPath: z.string().describe('Full path to the file'), + fileFullPath: z.string().describe('Full path or VS Code virtual-document URI of the source file'), + workingDirectory: z.string().optional().describe('Workspace directory used to select the correct VS Code window. Required for a virtual-document URI when multiple windows are open.'), line: z.number().int().describe('Line number (1-based) where the logpoint should be set'), logMessage: z.string().describe('Message to log when the line is reached. Wrap expressions in {curly braces} to interpolate runtime values.'), condition: z.string().optional().describe('Optional condition expression. When provided, the message is only logged if this expression evaluates to true.'), }, - }, async (args: { fileFullPath: string; line: number; logMessage: string; condition?: string }) => + }, async (args: { fileFullPath: string; workingDirectory?: string; line: number; logMessage: string; condition?: string }) => this.runTool('add_logpoint', () => debuggingHandler.handleAddLogpoint(args))); // Remove breakpoint tool server.registerTool('remove_breakpoint', { description: 'Remove a breakpoint that is no longer needed.', inputSchema: { - fileFullPath: z.string().describe('Full path to the file'), + fileFullPath: z.string().describe('Full path or VS Code virtual-document URI of the source file'), + workingDirectory: z.string().optional().describe('Workspace directory used to select the correct VS Code window. Required for a virtual-document URI when multiple windows are open.'), line: z.number().describe('Line number (1-based)'), }, - }, async (args: { fileFullPath: string; line: number }) => + }, async (args: { fileFullPath: string; workingDirectory?: string; line: number }) => this.runTool('remove_breakpoint', () => debuggingHandler.handleRemoveBreakpoint(args))); // Clear all breakpoints tool @@ -653,4 +656,4 @@ export class DebugMCPServer { isInitialized(): boolean { return this.initialized; } -} \ No newline at end of file +} diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 3849ff4..90203e9 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -1,3 +1,9 @@ +import * as fs from 'node:fs'; +import { isSourceUri } from './utils/sourceUri'; + +function toSourceUri(source: string): vscode.Uri { + return isSourceUri(source) ? vscode.Uri.parse(source, true) : vscode.Uri.file(source); +} // Copyright (c) Microsoft Corporation. import * as vscode from 'vscode'; @@ -51,6 +57,7 @@ export interface IDebuggingExecutor { getActiveSession(): DebugSessionInfo | undefined; getActiveFrameId?(): number | undefined; waitForDebugSessionReady(timeoutMs: number, signal?: AbortSignal): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'>; + getFileLineCount?(fileFullPath: string): Promise; dispose?(): Promise | void; } @@ -85,6 +92,16 @@ export class DebuggingExecutor implements IDebuggingExecutor { /** * Start a debugging session */ + public async getFileLineCount(fileFullPath: string): Promise { + if (isSourceUri(fileFullPath)) { + const uri = toSourceUri(fileFullPath); + const document = await vscode.workspace.openTextDocument(uri); + return document.lineCount; + } + const content = await fs.promises.readFile(fileFullPath, 'utf8'); + return content.length === 0 ? 0 : content.split(/\r?\n/).length; + } + public async startDebugging( workingDirectory: string, config: string | DebugConfiguration @@ -350,7 +367,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { */ public async addBreakpoint(fileFullPath: string, line: number, condition?: string, logMessage?: string): Promise { try { - const uri = vscode.Uri.file(fileFullPath); + const uri = toSourceUri(fileFullPath); const breakpoint = new vscode.SourceBreakpoint( new vscode.Location(uri, new vscode.Position(line - 1, 0)), true, @@ -369,7 +386,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { */ public async removeBreakpoint(fileFullPath: string, line: number): Promise { try { - const uri = vscode.Uri.file(fileFullPath); + const uri = toSourceUri(fileFullPath); const breakpoints = vscode.debug.breakpoints.filter(bp => { if (bp instanceof vscode.SourceBreakpoint) { return bp.location.uri.toString() === uri.toString() && @@ -746,7 +763,9 @@ export class DebuggingExecutor implements IDebuggingExecutor { .filter((breakpoint): breakpoint is vscode.SourceBreakpoint => breakpoint instanceof vscode.SourceBreakpoint) .map(breakpoint => ({ - fileFullPath: breakpoint.location.uri.fsPath, + fileFullPath: isSourceUri(breakpoint.location.uri.toString()) + ? breakpoint.location.uri.toString() + : breakpoint.location.uri.fsPath, line: breakpoint.location.range.start.line + 1, condition: breakpoint.condition, logMessage: breakpoint.logMessage diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index bcb41ce..6411c18 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -5,6 +5,7 @@ import { DebugConfigurationManager, IDebugConfigurationManager } from './utils/d import { DebugState } from './debugState'; import { IDebuggingExecutor } from './debuggingExecutor'; import { logger } from './utils/logger'; +import { isSourceUri } from './utils/sourceUri'; import { isSensitiveExpression, isSensitiveName, @@ -26,9 +27,9 @@ export interface IDebuggingHandler { handleContinue(): Promise; handlePause(): Promise; handleRestart(): Promise; - handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise; - handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise; - handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise; + handleAddBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; condition?: string }): Promise; + handleAddLogpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; logMessage: string; condition?: string }): Promise; + handleRemoveBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number }): Promise; handleClearAllBreakpoints(): Promise; handleListBreakpoints(): Promise; handleGetVariables(args: { variableNames: string[]; scope?: 'local' | 'global' | 'all' }): Promise; @@ -394,7 +395,7 @@ export class DebuggingHandler implements IDebuggingHandler { * Add a breakpoint at specified location. An optional condition makes it a * conditional breakpoint that only pauses when the expression is true. */ - public async handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise { + public async handleAddBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; condition?: string }): Promise { const { fileFullPath, line, condition } = args; try { @@ -446,7 +447,7 @@ export class DebuggingHandler implements IDebuggingHandler { * interpolated by the debug adapter) instead of pausing execution. An * optional condition only logs when the expression is true. */ - public async handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise { + public async handleAddLogpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; logMessage: string; condition?: string }): Promise { const { fileFullPath, line, logMessage, condition } = args; try { @@ -476,10 +477,11 @@ export class DebuggingHandler implements IDebuggingHandler { /** * Remove a breakpoint from specified location */ - public async handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise { + public async handleRemoveBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number }): Promise { const { fileFullPath, line } = args; try { + // Check if breakpoint exists at this location const breakpoints = this.executor.getBreakpoints(); const existingBreakpoint = breakpoints.find(bp => @@ -1018,6 +1020,9 @@ export class DebuggingHandler implements IDebuggingHandler { } private async getFileLineCount(fileFullPath: string): Promise { + if (this.executor.getFileLineCount) { + return await this.executor.getFileLineCount(fileFullPath); + } const content = await fs.promises.readFile(fileFullPath, 'utf8'); return content.length === 0 ? 0 : content.split(/\r?\n/).length; } diff --git a/src/routingDebuggingHandler.ts b/src/routingDebuggingHandler.ts index 03860d7..d58307d 100644 --- a/src/routingDebuggingHandler.ts +++ b/src/routingDebuggingHandler.ts @@ -4,6 +4,7 @@ import * as http from 'http'; import { IDebuggingHandler } from './debuggingHandler'; import { WorkspaceRegistry, WindowRegistration } from './utils/workspaceRegistry'; import { logger } from './utils/logger'; +import { isSourceUri } from './utils/sourceUri'; /** * Router-window handler (one instance per MCP session) that forwards every @@ -40,9 +41,9 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { * has no cached target we recover rather than fail - but only when a single * registered window makes the answer unambiguous. */ - private resolveTarget(pathHint?: string): WindowRegistration { + private resolveTarget(pathHint?: string, virtualSource = false): WindowRegistration { if (pathHint) { - const found = this.registry.findByPath(pathHint); + const found = virtualSource ? undefined : this.registry.findByPath(pathHint); const candidates = this.registry .list() .map((w) => `pid=${w.pid} port=${w.controlPort} folders=[${w.workspaceFolders.join(', ') || 'none'}]`) @@ -62,7 +63,7 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { } } if (!this.target) { - throw new Error(this.noTargetMessage(pathHint)); + throw new Error(this.noTargetMessage(pathHint, virtualSource)); } return this.target; } @@ -91,12 +92,21 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { return undefined; } - private noTargetMessage(pathHint?: string): string { + private noTargetMessage(pathHint?: string, virtualSource = false): string { const windows = this.registry.list(); const openList = windows .map((w) => (w.workspaceFolders.length ? w.workspaceFolders.join(', ') : '(no folder)')) .join('; '); if (pathHint) { + if (virtualSource) { + return ( + `DebugMCP could not select a VS Code window for virtual source URI "${pathHint}". ` + + 'Pass workingDirectory to identify the workspace that owns the debug session. ' + + (openList + ? `Currently registered workspaces: ${openList}.` + : 'No DebugMCP-enabled VS Code windows are currently registered.') + ); + } return ( `DebugMCP could not find an open VS Code window whose workspace contains "${pathHint}". ` + (openList @@ -113,8 +123,8 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { ); } - private async forward(op: string, args: unknown, pathHint?: string): Promise { - const target = this.resolveTarget(pathHint); + private async forward(op: string, args: unknown, pathHint?: string, virtualSource = false): Promise { + const target = this.resolveTarget(pathHint, virtualSource); logger.info(`Forwarding ${op} to pid=${target.pid} port=${target.controlPort}${pathHint ? '' : ' (cached target, no path hint)'}`); try { return await this.post(target, op, args); @@ -240,16 +250,19 @@ export class RoutingDebuggingHandler implements IDebuggingHandler { return this.forward('handleRestart', {}); } - public handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise { - return this.forward('handleAddBreakpoint', args, args.fileFullPath); + public handleAddBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; condition?: string }): Promise { + const virtualSource = !args.workingDirectory && isSourceUri(args.fileFullPath); + return this.forward('handleAddBreakpoint', args, args.workingDirectory || args.fileFullPath, virtualSource); } - public handleAddLogpoint(args: { fileFullPath: string; line: number; logMessage: string; condition?: string }): Promise { - return this.forward('handleAddLogpoint', args, args.fileFullPath); + public handleAddLogpoint(args: { fileFullPath: string; workingDirectory?: string; line: number; logMessage: string; condition?: string }): Promise { + const virtualSource = !args.workingDirectory && isSourceUri(args.fileFullPath); + return this.forward('handleAddLogpoint', args, args.workingDirectory || args.fileFullPath, virtualSource); } - public handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise { - return this.forward('handleRemoveBreakpoint', args, args.fileFullPath); + public handleRemoveBreakpoint(args: { fileFullPath: string; workingDirectory?: string; line: number }): Promise { + const virtualSource = !args.workingDirectory && isSourceUri(args.fileFullPath); + return this.forward('handleRemoveBreakpoint', args, args.workingDirectory || args.fileFullPath, virtualSource); } public handleClearAllBreakpoints(): Promise { diff --git a/src/test/debuggingHandler.test.ts b/src/test/debuggingHandler.test.ts index 5bc8f8c..0f4a194 100644 --- a/src/test/debuggingHandler.test.ts +++ b/src/test/debuggingHandler.test.ts @@ -4,6 +4,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as vscode from 'vscode'; import { DebugState } from '../debugState'; import { DebuggingHandler } from '../debuggingHandler'; import { IDebuggingExecutor } from '../debuggingExecutor'; @@ -222,6 +223,65 @@ suite('DebuggingHandler waitForStateChange (event-driven)', () => { }); }); +suite('DebuggingHandler virtual source breakpoints', () => { + test('passes an AL-style virtual .dal URI through breakpoint, logpoint, and removal operations', async () => { + const scheme = `al-preview-test-${Date.now()}`; + const source = `${scheme}://AlLang/437dbf0e84ff417a965ded2bb9650972/Table/18/Customer.dal`; + const added: Array<{ fileFullPath: string; line: number; logMessage?: string }> = []; + let removedPath: string | undefined; + let breakpoints: any[] = []; + const provider = vscode.workspace.registerTextDocumentContentProvider(scheme, { + provideTextDocumentContent: () => 'table 18 Customer\n{\n}' + }); + const executor: IDebuggingExecutor = { + startDebugging: async () => true, + debugTestAtCursor: async () => ({ started: true, runComplete: Promise.resolve() }), + stopDebugging: async () => { /* noop */ }, + stepOver: async () => { /* noop */ }, + stepInto: async () => { /* noop */ }, + stepOut: async () => { /* noop */ }, + continue: async () => { /* noop */ }, + pause: async () => { /* noop */ }, + restart: async () => { /* noop */ }, + addBreakpoint: async (fileFullPath, line, _condition, logMessage) => { added.push({ fileFullPath, line, logMessage }); }, + removeBreakpoint: async (fileFullPath) => { removedPath = fileFullPath; }, + getCurrentDebugState: async () => new DebugState(), + getVariables: async () => ({}), + getVariableChildren: async () => [], + evaluateExpression: async () => ({}), + getBreakpoints: () => breakpoints, + clearAllBreakpoints: () => { /* noop */ }, + hasActiveSession: async () => false, + getActiveSession: () => undefined, + waitForDebugSessionReady: async () => 'no-session' + }; + + try { + const handler = new DebuggingHandler(executor, {} as any, 30); + const breakpointResult = await handler.handleAddBreakpoint({ fileFullPath: source, line: 2 }); + const logpointResult = await handler.handleAddLogpoint({ + fileFullPath: source, + line: 1, + logMessage: 'Customer {Rec.SystemId}' + }); + breakpoints = [{ fileFullPath: source, line: 2 }]; + const removalResult = await handler.handleRemoveBreakpoint({ fileFullPath: source, line: 2 }); + + assert.strictEqual(added[0].fileFullPath, source); + assert.strictEqual(added[0].line, 2); + assert.strictEqual(added[1].fileFullPath, source); + assert.strictEqual(added[1].line, 1); + assert.strictEqual(added[1].logMessage, 'Customer {Rec.SystemId}'); + assert.strictEqual(removedPath, source); + assert.match(breakpointResult, /Breakpoint added/); + assert.match(logpointResult, /Logpoint added/); + assert.match(removalResult, /Breakpoint removed/); + } finally { + provider.dispose(); + } + }); +}); + /** * Regression tests for continue against a process that resumes and keeps * running (a server, an event loop) rather than stopping again. diff --git a/src/test/routing.test.ts b/src/test/routing.test.ts index 1ee2a62..96bcb02 100644 --- a/src/test/routing.test.ts +++ b/src/test/routing.test.ts @@ -147,6 +147,55 @@ suite('Multi-window routing', () => { assert.strictEqual(handlerB.calls.length, 0); }); + test('virtual source breakpoint uses workingDirectory to select its window', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + const handlerA = new RecordingHandler('A'); + const handlerB = new RecordingHandler('B'); + await startWindow('a.json', [repoA], handlerA); + await startWindow('b.json', [repoB], handlerB); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + const result = await routing.handleAddBreakpoint({ + fileFullPath: 'al-preview://AlLang/app/Table/18/Customer.dal', + workingDirectory: repoB, + line: 1 + }); + + assert.strictEqual(result, 'B:addBp'); + assert.strictEqual(handlerA.calls.length, 0); + }); + + test('virtual source breakpoint falls back to the sole registered window', async () => { + const repoA = path.join(dir, 'repoA'); + const handlerA = new RecordingHandler('A'); + await startWindow('a.json', [repoA], handlerA); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + const result = await routing.handleAddBreakpoint({ + fileFullPath: 'al-preview://AlLang/app/Table/18/Customer.dal', + line: 1 + }); + + assert.strictEqual(result, 'A:addBp'); + }); + + test('virtual source breakpoint requires workingDirectory when multiple windows are open', async () => { + const repoA = path.join(dir, 'repoA'); + const repoB = path.join(dir, 'repoB'); + await startWindow('a.json', [repoA], new RecordingHandler('A')); + await startWindow('b.json', [repoB], new RecordingHandler('B')); + + const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir)); + await assert.rejects( + () => routing.handleAddBreakpoint({ + fileFullPath: 'al-preview://AlLang/app/Table/18/Customer.dal', + line: 1 + }), + /Pass workingDirectory/ + ); + }); + test('throws a helpful error when no window owns the path', async () => { const repoA = path.join(dir, 'repoA'); const repoB = path.join(dir, 'repoB'); @@ -260,4 +309,3 @@ suite('Multi-window routing', () => { ); }); }); - diff --git a/src/test/sourceUri.test.ts b/src/test/sourceUri.test.ts new file mode 100644 index 0000000..e72f2a2 --- /dev/null +++ b/src/test/sourceUri.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as path from 'path'; +import { isSourceUri, toSourceUri } from '../utils/sourceUri'; + +suite('Source URI handling', () => { + test('preserves an AL virtual .dal document URI', () => { + const source = 'al-preview://AlLang/437dbf0e84ff417a965ded2bb9650972/Table/18/Customer.dal'; + const uri = toSourceUri(source); + + assert.strictEqual(isSourceUri(source), true); + assert.strictEqual(uri.scheme, 'al-preview'); + assert.strictEqual(uri.authority, 'AlLang'); + assert.strictEqual(uri.path, '/437dbf0e84ff417a965ded2bb9650972/Table/18/Customer.dal'); + assert.strictEqual( + uri.toString(), + 'al-preview://allang/437dbf0e84ff417a965ded2bb9650972/Table/18/Customer.dal' + ); + }); + + test('keeps native filesystem paths as file URIs', () => { + const source = path.join(path.sep, 'workspace', 'src', 'main.ts'); + const uri = toSourceUri(source); + + assert.strictEqual(isSourceUri(source), false); + assert.strictEqual(uri.scheme, 'file'); + assert.strictEqual(uri.fsPath, source); + }); + + test('does not mistake a Windows drive letter for a URI scheme', () => { + assert.strictEqual(isSourceUri('C:\\workspace\\src\\main.ts'), false); + }); +}); diff --git a/src/utils/sourceUri.ts b/src/utils/sourceUri.ts new file mode 100644 index 0000000..4f4d775 --- /dev/null +++ b/src/utils/sourceUri.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. + +import type * as vscode from 'vscode'; + +/** + * True when a source location is a URI rather than a native filesystem path. + * + * The drive letter in a Windows path looks like a URI scheme, so explicitly + * exclude drive-letter paths before applying the generic scheme check. + */ +export function isSourceUri(source: string): boolean { + if (/^[a-zA-Z]:[\\/]/.test(source)) { + return false; + } + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(source); +} + +/** Preserve virtual-document schemes while retaining native path behavior. */ +export function toSourceUri(source: string): vscode.Uri { + const vscodeModule: typeof import('vscode') = require('vscode'); + return isSourceUri(source) ? vscodeModule.Uri.parse(source, true) : vscodeModule.Uri.file(source); +}