From fbaef297a57f28325020951420a68b537417a106 Mon Sep 17 00:00:00 2001 From: Omar Elkhouly Date: Wed, 13 May 2026 18:47:19 +0200 Subject: [PATCH] highlighting source lines that are attributed to machine code --- __mocks__/vscode.js | 12 ++ src/desktop/extension.ts | 3 + .../source-file-highlighting.test.ts | 165 ++++++++++++++++++ .../source-file-highlighting.ts | 79 +++++++++ 4 files changed, 259 insertions(+) create mode 100644 src/features/source-file-highlighting/source-file-highlighting.test.ts create mode 100644 src/features/source-file-highlighting/source-file-highlighting.ts diff --git a/__mocks__/vscode.js b/__mocks__/vscode.js index bf4fb789..b766c2a8 100644 --- a/__mocks__/vscode.js +++ b/__mocks__/vscode.js @@ -47,6 +47,15 @@ class MockTreeItem { } } +class MockRange { + start; + end; + constructor(startLine, startCharacter, endLine, endCharacter) { + this.start = { line: startLine, character: startCharacter }; + this.end = { line: endLine, character: endCharacter }; + } +} + module.exports = { EventEmitter: jest.fn(() => { const callbacks = []; @@ -60,6 +69,7 @@ module.exports = { }; }), Uri: URI, + Range: MockRange, TreeItem: MockTreeItem, TreeItemCollapsibleState: MockTreeItemCollapsibleState, window: { @@ -86,6 +96,8 @@ module.exports = { reveal: jest.fn().mockResolvedValue(undefined), })), registerTreeDataProvider: jest.fn(() => ({ dispose: jest.fn() })), + createTextEditorDecorationType: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), showErrorMessage: jest.fn(), showInformationMessage: jest.fn(() => Promise.resolve(undefined)), showWarningMessage: jest.fn(), diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index f94e8b52..ae350cf6 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -27,6 +27,7 @@ import { GenericCommands } from '../features/generic-commands'; import { ComponentViewer } from '../views/component-viewer/component-viewer'; import { ComponentViewerTreeDataProvider } from '../views/component-viewer/component-viewer-tree-view'; import { CorePeripherals } from '../views/core-peripherals/core-peripherals'; +import { SourceFileHighlighting } from '../features/source-file-highlighting/source-file-highlighting'; const BUILTIN_TOOLS_PATHS = [ 'tools/pyocd/pyocd', @@ -58,6 +59,7 @@ export const activate = async (context: vscode.ExtensionContext): Promise corePeripheralsTreeDataProvider = new ComponentViewerTreeDataProvider(); const componentViewer = new ComponentViewer(context, componentViewerTreeDataProvider); const corePeripherals = new CorePeripherals(context, corePeripheralsTreeDataProvider); + const sourceFileHighlighting = new SourceFileHighlighting(context); addToolsToPath(context, BUILTIN_TOOLS_PATHS); // Activate generic commands @@ -69,6 +71,7 @@ export const activate = async (context: vscode.ExtensionContext): Promise cpuStates.activate(gdbtargetDebugTracker); cpuStatesCommands.activate(context, cpuStates); cpuStatesStatusBarItem.activate(context, cpuStates); + sourceFileHighlighting.activate(); // Live Watch view logger.debug('Activating Live Watch Tree Data Provider'); if (!await liveWatchTreeDataProvider.activate(gdbtargetDebugTracker)) { diff --git a/src/features/source-file-highlighting/source-file-highlighting.test.ts b/src/features/source-file-highlighting/source-file-highlighting.test.ts new file mode 100644 index 00000000..4b8429b8 --- /dev/null +++ b/src/features/source-file-highlighting/source-file-highlighting.test.ts @@ -0,0 +1,165 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// generated with AI + +import * as vscode from 'vscode'; +import { SourceFileHighlighting } from './source-file-highlighting'; +import { debugSessionFactory, extensionContextFactory } from '../../__test__/vscode.factory'; + +type ActiveDebugSessionListener = (session: vscode.DebugSession | undefined) => void; +type ActiveTextEditorListener = (editor: vscode.TextEditor | undefined) => void; + +function makeDocument(options: { + fileName?: string; + lineCount?: number; + scheme?: string; +} = {}): vscode.TextDocument { + const fileName = options.fileName ?? '/workspace/source/main.c'; + const uri = options.scheme === 'file' || options.scheme === undefined + ? vscode.Uri.file(fileName) + : { ...vscode.Uri.file(fileName), scheme: options.scheme }; + return { + fileName, + uri, + lineCount: options.lineCount ?? 12 + } as vscode.TextDocument; +} + +function makeEditor(document: vscode.TextDocument = makeDocument()): jest.Mocked { + return { + document, + setDecorations: jest.fn() + } as unknown as jest.Mocked; +} + +async function waitForAsyncCallbacks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('SourceFileHighlighting', () => { + let activeDebugSessionListener: ActiveDebugSessionListener; + let activeTextEditorListener: ActiveTextEditorListener; + let debugSessionDisposable: vscode.Disposable; + let editorDisposable: vscode.Disposable; + + beforeEach(() => { + jest.clearAllMocks(); + debugSessionDisposable = { dispose: jest.fn() }; + editorDisposable = { dispose: jest.fn() }; + (vscode.debug.onDidChangeActiveDebugSession as jest.Mock).mockImplementation((listener: ActiveDebugSessionListener) => { + activeDebugSessionListener = listener; + return debugSessionDisposable; + }); + (vscode.window.onDidChangeActiveTextEditor as jest.Mock).mockImplementation((listener: ActiveTextEditorListener) => { + activeTextEditorListener = listener; + return editorDisposable; + }); + }); + + it('registers active debug session and active editor listeners', () => { + const context = extensionContextFactory(); + const sourceFileHighlighting = new SourceFileHighlighting(context); + + sourceFileHighlighting.activate(); + + expect(vscode.debug.onDidChangeActiveDebugSession).toHaveBeenCalledWith(expect.any(Function)); + expect(vscode.window.onDidChangeActiveTextEditor).toHaveBeenCalledWith(expect.any(Function)); + expect(context.subscriptions).toContain(debugSessionDisposable); + }); + + it('requests breakpoint locations for the active file and highlights unique executable lines', async () => { + const context = extensionContextFactory(); + const sourceFileHighlighting = new SourceFileHighlighting(context); + const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' }); + (debugSession.customRequest as jest.Mock).mockResolvedValueOnce({ + breakpoints: [ + { line: 2 }, + { line: 7 }, + { line: 2 } + ] + }); + const editor = makeEditor(makeDocument({ fileName: '/workspace/source/main.c', lineCount: 20 })); + + sourceFileHighlighting.activate(); + activeDebugSessionListener(debugSession); + activeTextEditorListener(editor); + await waitForAsyncCallbacks(); + + expect(debugSession.customRequest).toHaveBeenCalledWith('breakpointLocations', { + source: { path: '/workspace/source/main.c' }, + line: 1, + endLine: 20 + }); + expect(editor.setDecorations).toHaveBeenCalledTimes(1); + expect(editor.setDecorations).toHaveBeenCalledWith( + (vscode.window.createTextEditorDecorationType as jest.Mock).mock.results[0].value, + [ + { range: new vscode.Range(1, 0, 1, 0) }, + { range: new vscode.Range(6, 0, 6, 0) } + ] + ); + }); + + it('does not request breakpoint locations before an active debug session exists', async () => { + const context = extensionContextFactory(); + const sourceFileHighlighting = new SourceFileHighlighting(context); + const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' }); + const editor = makeEditor(); + + sourceFileHighlighting.activate(); + activeTextEditorListener(editor); + await waitForAsyncCallbacks(); + + expect(debugSession.customRequest).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it('does not request breakpoint locations for non-file editors', async () => { + const context = extensionContextFactory(); + const sourceFileHighlighting = new SourceFileHighlighting(context); + const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' }); + const editor = makeEditor(makeDocument({ scheme: 'untitled' })); + + sourceFileHighlighting.activate(); + activeDebugSessionListener(debugSession); + activeTextEditorListener(editor); + await waitForAsyncCallbacks(); + + expect(debugSession.customRequest).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it('does not decorate when the adapter returns no breakpoint locations', async () => { + const context = extensionContextFactory(); + const sourceFileHighlighting = new SourceFileHighlighting(context); + const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' }); + (debugSession.customRequest as jest.Mock).mockResolvedValueOnce(undefined); + const editor = makeEditor(); + + sourceFileHighlighting.activate(); + activeDebugSessionListener(debugSession); + activeTextEditorListener(editor); + await waitForAsyncCallbacks(); + + expect(debugSession.customRequest).toHaveBeenCalledWith('breakpointLocations', { + source: { path: '/workspace/source/main.c' }, + line: 1, + endLine: 12 + }); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/source-file-highlighting/source-file-highlighting.ts b/src/features/source-file-highlighting/source-file-highlighting.ts new file mode 100644 index 00000000..e6d7d743 --- /dev/null +++ b/src/features/source-file-highlighting/source-file-highlighting.ts @@ -0,0 +1,79 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as vscode from 'vscode'; +import { DebugProtocol } from '@vscode/debugprotocol'; + +export class SourceFileHighlighting { + private activeDebugSession: vscode.DebugSession | undefined; + private context: vscode.ExtensionContext; + private executableLineDecorator = vscode.window.createTextEditorDecorationType({ + // turn it red for testing + backgroundColor: 'rgba(222, 199, 199, 0.3)', + // only highlight the margin of the line to avoid obscuring code + isWholeLine: true, + }); + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + public activate(): void { + this.registerToTrackerEvents(); + vscode.window.onDidChangeActiveTextEditor(editor => { + this.handleOnDidChangeActiveTextEditor(editor); + }); + } + + private registerToTrackerEvents(): void { + const onDidChangeActiveDebugSessionDisposable = vscode.debug.onDidChangeActiveDebugSession(session => { + this.activeDebugSession = session; + }); + this.context.subscriptions.push(onDidChangeActiveDebugSessionDisposable); + } + + private async handleOnDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined): Promise { + if (!editor || !this.activeDebugSession) { + return; + } + const breakpointLocations = await this.getBreakpointLocations(editor); + if (!breakpointLocations) { + return; + } + const executableLines = new Set(breakpointLocations.breakpoints.map((bp: DebugProtocol.BreakpointLocation) => bp.line)); + const decorations: vscode.DecorationOptions[] = Array.from(executableLines).map((exeline: number) => { + const line = exeline - 1; // Convert to 0-based index + return { + range: new vscode.Range(line, 0, line, 0), + }; + }); + editor.setDecorations(this.executableLineDecorator, decorations); + } + + private async getBreakpointLocations(editor: vscode.TextEditor): Promise { + if(editor.document.uri.scheme !== 'file') { + return; + } + const currentSourceFile = editor.document.fileName; + const args : DebugProtocol.BreakpointLocationsArguments = { + source: { path: currentSourceFile }, + line: 1, + endLine: editor.document.lineCount, // Requesting breakpoint locations for the whole file + }; + const breakpointLocations = await this.activeDebugSession?.customRequest('breakpointLocations', args); + return breakpointLocations; + } +}