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);
+}