diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc55e09..8baef67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to DebugMCP will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
+## [Unreleased]
+
+### Added
+- Breakpoint, logpoint, and removal tools now support VS Code virtual-document URIs, including Business Central `al-preview:` `.dal` sources. An optional `workingDirectory` selects the correct workspace when multiple editor windows are open.
+
## [2.3.4] - 2026-09-03
### Added
@@ -117,4 +122,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- Initial release
- Core debugging capabilities via MCP protocol
- VS Code Debug Adapter Protocol integration
-- Automatic MCP server startup on extension activation
\ No newline at end of file
+- Automatic MCP server startup on extension activation
diff --git a/README.md b/README.md
index 15a5bd8..ee848d3 100644
--- a/README.md
+++ b/README.md
@@ -60,9 +60,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') |
@@ -156,6 +156,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=rebornix.ruby) | `.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
@@ -499,4 +509,4 @@ If DebugMCP has helped you debug faster, please consider giving it a star on Git
MIT License - See [LICENSE](LICENSE.txt) for details
-This extension was created by **Oz Zafar**, **Ori Bar-Ilan** and **Karin Brisker**.
\ No newline at end of file
+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 eaef45b..8aef03d 100644
--- a/docs/architecture/debugMCPServer.md
+++ b/docs/architecture/debugMCPServer.md
@@ -50,6 +50,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.
@@ -117,7 +119,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 |
@@ -127,4 +129,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 4c737b5..30a4a30 100644
--- a/docs/architecture/debuggingHandler.md
+++ b/docs/architecture/debuggingHandler.md
@@ -11,6 +11,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
@@ -59,6 +60,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 253c4f8..6f9368a 100644
--- a/src/debugMCPServer.ts
+++ b/src/debugMCPServer.ts
@@ -263,33 +263,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
@@ -637,4 +640,4 @@ export class DebugMCPServer {
isInitialized(): boolean {
return this.initialized;
}
-}
\ No newline at end of file
+}
diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts
index 12fdc69..cfc5943 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 { toSourceUri } 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;
@@ -419,7 +420,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 {
@@ -429,12 +430,12 @@ export class DebuggingHandler implements IDebuggingHandler {
// Validate the line exists so we fail clearly instead of setting an
// unbound breakpoint past the end of the file.
- const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath));
+ const uri = toSourceUri(fileFullPath);
+ const document = await vscode.workspace.openTextDocument(uri);
if (line > document.lineCount) {
throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`);
}
- const uri = vscode.Uri.file(fileFullPath);
await this.executor.addBreakpoint(uri, line, condition);
const conditionInfo = condition ? ` (condition: ${condition})` : '';
@@ -472,7 +473,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 {
@@ -485,12 +486,12 @@ export class DebuggingHandler implements IDebuggingHandler {
// Validate the line exists so we fail clearly instead of setting an
// unbound logpoint past the end of the file.
- const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath));
+ const uri = toSourceUri(fileFullPath);
+ const document = await vscode.workspace.openTextDocument(uri);
if (line > document.lineCount) {
throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`);
}
- const uri = vscode.Uri.file(fileFullPath);
await this.executor.addBreakpoint(uri, line, condition, logMessage);
const conditionInfo = condition ? ` (condition: ${condition})` : '';
@@ -503,11 +504,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 {
- const uri = vscode.Uri.file(fileFullPath);
+ const uri = toSourceUri(fileFullPath);
// Check if breakpoint exists at this location
const breakpoints = this.executor.getBreakpoints();
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 39ded7e..25f651d 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';
@@ -209,6 +210,70 @@ 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<{ uri: vscode.Uri; line: number; logMessage?: string }> = [];
+ let removedUri: vscode.Uri | undefined;
+ let breakpoints: vscode.Breakpoint[] = [];
+ 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 (uri, line, _condition, logMessage) => { added.push({ uri, line, logMessage }); },
+ removeBreakpoint: async (uri) => { removedUri = uri; },
+ 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 = [new vscode.SourceBreakpoint(new vscode.Location(added[0].uri, new vscode.Position(1, 0)))];
+ const removalResult = await handler.handleRemoveBreakpoint({ fileFullPath: source, line: 2 });
+
+ assert.strictEqual(added[0].uri.scheme, scheme);
+ assert.strictEqual(added[0].uri.authority, 'AlLang');
+ assert.strictEqual(
+ added[0].uri.path,
+ '/437dbf0e84ff417a965ded2bb9650972/Table/18/Customer.dal'
+ );
+ assert.strictEqual(added[0].line, 2);
+ assert.strictEqual(added[1].uri.toString(), added[0].uri.toString());
+ assert.strictEqual(added[1].line, 1);
+ assert.strictEqual(added[1].logMessage, 'Customer {Rec.SystemId}');
+ assert.strictEqual(removedUri?.toString(), added[0].uri.toString());
+ 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..6c1fd12
--- /dev/null
+++ b/src/utils/sourceUri.ts
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+
+import * 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 {
+ return isSourceUri(source) ? vscode.Uri.parse(source, true) : vscode.Uri.file(source);
+}