Creating a Custom VS Code Extension for Debugging

Creating a Custom VS Code Extension for Debugging

Why Create Your Own Debugging Extension?

As developers, we spend a significant portion of our time debugging code. Visual Studio Code offers a robust debugging system out of the box, but there are always specific pain points in our workflow that could use improvement. Maybe you're tired of setting the same breakpoints repeatedly, or perhaps you wish you could visualize certain data structures more effectively during debugging sessions.

I recently faced this challenge while working on a complex React application. The standard debugging tools were helpful, but I found myself repeating the same debugging patterns over and over. That's when I decided to build a custom extension to streamline my workflow. In this article, I'll walk you through the process of creating your own debugging extension for VS Code, from initial setup to publishing.

Setting Up Your Development Environment

Before diving into code, let's make sure we have everything we need to develop a VS Code extension:

  1. Node.js and npm: VS Code extensions are built with TypeScript/JavaScript, so you'll need Node.js installed (preferably version 14 or later).
  2. Yeoman and VS Code Extension Generator: These tools simplify the extension creation process.
  3. Visual Studio Code: You'll need this not just for development but also for testing your extension.

Let's start by installing the required tools if you don't have them already:


# Install Yeoman and the VS Code Extension Generator globally
npm install -g yo generator-code

Scaffolding Your Extension

With the tools installed, we can generate the scaffolding for our extension. Open your terminal and run:


# Create a new extension
yo code

You'll be prompted to provide some information about your extension. For our debugging extension, I recommend the following options:

  • Select "New Extension (TypeScript)"
  • Name your extension something descriptive like "custom-debug-helper"
  • Provide a brief description, like "Enhance VS Code's debugging experience with custom visualizations and tools"
  • Choose "Yes" when asked if you want to initialize a git repository

This will generate a basic extension structure with all the necessary files. Navigate to your project folder:


cd custom-debug-helper
code .

Understanding the Extension Structure

Before we add our debugging functionality, let's understand what files were created and their purpose:

  • package.json: Contains metadata about your extension and defines contributions (commands, views, etc.)
  • src/extension.ts: The main entry point for your extension's code
  • .vscode/launch.json: Configuration for debugging your extension
  • tsconfig.json: TypeScript configuration

The heart of our extension will be in src/extension.ts, which already contains a sample command implementation. We'll modify this file to add our debugging features.

Adding Debugging Functionality

VS Code's extension API provides several ways to interact with the debugging system. For our extension, we'll focus on:

  1. Creating custom debug configurations
  2. Adding specialized breakpoint handling
  3. Providing visualizations for debugging data

Let's start by updating our package.json to declare the debugging contributions:


"contributes": {
  "commands": [
    {
      "command": "custom-debug-helper.startCustomDebugging",
      "title": "Start Custom Debugging Session"
    },
    {
      "command": "custom-debug-helper.visualizeObject",
      "title": "Visualize Object Structure"
    }
  ],
  "breakpoints": [
    {
      "language": "javascript"
    },
    {
      "language": "typescript"
    }
  ],
  "debuggers": [
    {
      "type": "custom-debugger",
      "label": "Custom JavaScript Debugger",
      "program": "./out/debuggerRuntime.js",
      "runtime": "node",
      "configurationAttributes": {
        "launch": {
          "required": ["program"],
          "properties": {
            "program": {
              "type": "string",
              "description": "The program to debug"
            },
            "enableCustomVisualizations": {
              "type": "boolean",
              "description": "Enable custom data visualizations",
              "default": true
            }
          }
        }
      },
      "initialConfigurations": [
        {
          "type": "custom-debugger",
          "request": "launch",
          "name": "Launch with Custom Debugger",
          "program": "${file}",
          "enableCustomVisualizations": true
        }
      ]
    }
  ]
}

Implementing the Core Debugging Logic

Now, let's implement the core debugging functionality in our extension. Create a new file called src/debuggerRuntime.ts that will handle our custom debugger:


import * as vscode from 'vscode';
import { EventEmitter } from 'events';

/**
 * A custom debugger runtime that enhances VS Code's debugging experience
 */
export class CustomDebuggerRuntime extends EventEmitter {
  private _variableHandlers: Map string> = new Map();
  private _breakpointManager: BreakpointManager;
  
  constructor() {
    super();
    this._breakpointManager = new BreakpointManager();
    
    // Register default variable visualizers
    this.registerVariableHandler('Array', this._visualizeArray);
    this.registerVariableHandler('Object', this._visualizeObject);
  }
  
  /**
   * Start debugging the given program
   */
  public start(program: string, enableVisualizations: boolean): void {
    // Implementation details for starting the debugger
    // This would typically involve launching the program under a debugging protocol
    console.log(`Starting debug session for ${program} with visualizations ${enableVisualizations ? 'enabled' : 'disabled'}`);
    
    // For a real implementation, you'd connect to the Debug Adapter Protocol here
  }
  
  /**
   * Register a custom handler for visualizing variables of a specific type
   */
  public registerVariableHandler(typeName: string, handler: (value: any) => string): void {
    this._variableHandlers.set(typeName, handler);
  }
  
  /**
   * Visualize an array with custom formatting
   */
  private _visualizeArray(array: any[]): string {
    // Example implementation for custom array visualization
    if (!Array.isArray(array)) {
      return 'Not an array';
    }
    
    return `Array(${array.length}) ${array.length > 0 ? '[...]' : '[]'}`;
  }
  
  /**
   * Visualize an object with custom formatting
   */
  private _visualizeObject(obj: any): string {
    // Example implementation for custom object visualization
    if (typeof obj !== 'object' || obj === null) {
      return 'Not an object';
    }
    
    const keys = Object.keys(obj);
    return `Object {${keys.join(', ')}}`;
  }
}

/**
 * Manages custom breakpoints
 */
class BreakpointManager {
  private _breakpoints: Map = new Map();
  
  // Implementation of breakpoint management logic
}

Next, we need to update our main extension file (src/extension.ts) to integrate our custom debugger:


import * as vscode from 'vscode';
import { CustomDebuggerRuntime } from './debuggerRuntime';

export function activate(context: vscode.ExtensionContext) {
  console.log('Custom Debug Helper extension is active');
  
  // Register a command to start custom debugging
  let startDebuggingCmd = vscode.commands.registerCommand('custom-debug-helper.startCustomDebugging', async () => {
    // Get the active text editor
    const activeEditor = vscode.window.activeTextEditor;
    if (!activeEditor) {
      vscode.window.showErrorMessage('No active editor found');
      return;
    }
    
    // Get the current file path
    const filePath = activeEditor.document.uri.fsPath;
    
    // Start a debugging session
    const debugConfiguration = {
      type: 'custom-debugger',
      request: 'launch',
      name: 'Debug Current File',
      program: filePath,
      enableCustomVisualizations: true
    };
    
    // Start debugging
    vscode.debug.startDebugging(undefined, debugConfiguration);
  });
  
  // Register a command to visualize objects during debugging
  let visualizeObjectCmd = vscode.commands.registerCommand('custom-debug-helper.visualizeObject', async () => {
    // Get the current debug session
    const session = vscode.debug.activeDebugSession;
    if (!session) {
      vscode.window.showErrorMessage('No active debug session');
      return;
    }
    
    // Implement visualization logic for the selected variable
    // This could open a custom webview with an interactive visualization
    
    vscode.window.showInformationMessage('Visualizing object...');
    
    // In a real extension, you would get the selected variable from the debug session
    // and create a visualization for it
  });
  
  context.subscriptions.push(startDebuggingCmd, visualizeObjectCmd);
}

export function deactivate() {
  // Clean up resources
}

Creating a Custom Debug Interface

To provide a better user experience, let's create a custom UI for our debugger using VS Code's webview API. This will allow us to show visualizations that aren't possible in the standard debug views.

First, let's create a new file src/visualizerView.ts:


import * as vscode from 'vscode';

export class VisualizerPanel {
  public static currentPanel: VisualizerPanel | undefined;
  private readonly _panel: vscode.WebviewPanel;
  private _disposables: vscode.Disposable[] = [];
  
  private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
    this._panel = panel;
    
    // Set the webview's initial html content
    this._update();
    
    // Listen for when the panel is disposed
    // This happens when the user closes the panel or when the panel is closed programmatically
    this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
    
    // Update the content based on view changes
    this._panel.onDidChangeViewState(
      e => {
        if (this._panel.visible) {
          this._update();
        }
      },
      null,
      this._disposables
    );
  }
  
  public static createOrShow(extensionUri: vscode.Uri) {
    const column = vscode.window.activeTextEditor
      ? vscode.window.activeTextEditor.viewColumn
      : undefined;
      
    // If we already have a panel, show it
    if (VisualizerPanel.currentPanel) {
      VisualizerPanel.currentPanel._panel.reveal(column);
      return;
    }
    
    // Otherwise, create a new panel
    const panel = vscode.window.createWebviewPanel(
      'debugVisualizer',
      'Debug Visualizer',
      column || vscode.ViewColumn.One,
      {
        // Enable JavaScript in the webview
        enableScripts: true,
        
        // Restrict the webview to only load resources from the extension
        localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'media')]
      }
    );
    
    VisualizerPanel.currentPanel = new VisualizerPanel(panel, extensionUri);
  }
  
  public dispose() {
    VisualizerPanel.currentPanel = undefined;
    
    // Clean up our resources
    this._panel.dispose();
    
    while (this._disposables.length) {
      const disposable = this._disposables.pop();
      if (disposable) {
        disposable.dispose();
      }
    }
  }
  
  /**
   * Renders visualization content in the webview
   */
  public visualizeData(data: any) {
    // Send the data to the webview
    this._panel.webview.postMessage({
      type: 'update',
      data: data
    });
  }
  
  private _update() {
    const webview = this._panel.webview;
    
    // Set the HTML content
    webview.html = this._getHtmlForWebview(webview);
  }
  
  private _getHtmlForWebview(webview: vscode.Webview) {
    // Return HTML content for the webview
    return `
      
      
        
        
        Debug Visualizer
        
      
      
        

No data to visualize yet. Select a variable in the debug view and click "Visualize Object".

`; } }

Integrating with VS Code's Debug Adapter Protocol

To make our extension truly useful, we need to integrate with VS Code's Debug Adapter Protocol (DAP). This is a more advanced topic, but here's a simplified implementation to get you started:


// src/debugAdapter.ts
import {
  Logger, logger,
  LoggingDebugSession,
  InitializedEvent, TerminatedEvent, StoppedEvent, OutputEvent,
  Thread, StackFrame, Scope, Source, Handles
} from 'vscode-debugadapter';
import { DebugProtocol } from 'vscode-debugprotocol';
import { CustomDebuggerRuntime } from './debuggerRuntime';
import * as path from 'path';

export class CustomDebugAdapter extends LoggingDebugSession {
  private _runtime: CustomDebuggerRuntime;
  private _variableHandles = new Handles();
  
  public constructor() {
    super("custom-debugger-log.txt");
    this._runtime = new CustomDebuggerRuntime();
    
    // Setup event handlers
    this._runtime.on('stopOnEntry', () => {
      this.sendEvent(new StoppedEvent('entry', 1));
    });
    
    this._runtime.on('stopOnStep', () => {
      this.sendEvent(new StoppedEvent('step', 1));
    });
    
    this._runtime.on('stopOnBreakpoint', () => {
      this.sendEvent(new StoppedEvent('breakpoint', 1));
    });
    
    this._runtime.on('output', (output: string) => {
      this.sendEvent(new OutputEvent(`${output}\n`));
    });
    
    this._runtime.on('end', () => {
      this.sendEvent(new TerminatedEvent());
    });
  }
  
  // Implement the Debug Adapter Protocol methods here
  // This is a simplified version - a real implementation would be more complex
  
  protected initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments) {
    response.body = response.body || {};
    
    // Capabilities of this debug adapter
    response.body.supportsConfigurationDoneRequest = true;
    response.body.supportsEvaluateForHovers = true;
    response.body.supportsStepBack = false;
    response.body.supportsSetVariable = true;
    response.body.supportsFunctionBreakpoints = true;
    
    this.sendResponse(response);
    this.sendEvent(new InitializedEvent());
  }
  
  protected launchRequest(response: DebugProtocol.LaunchResponse, args: any) {
    // Start the program
    this._runtime.start(args.program, args.enableCustomVisualizations);
    this.sendResponse(response);
  }
  
  // Add more protocol implementations...
}

Testing Your Extension

Before packaging your extension for distribution, it's essential to test it thoroughly. VS Code makes this easy with its built-in extension debugging capabilities:

  1. Press F5 in your extension project to launch a new VS Code window with your extension loaded
  2. In the new window, open a JavaScript or TypeScript file
  3. Try out your custom debugging commands from the Command Palette (Ctrl+Shift+P)
  4. Set breakpoints and test your visualizations

During testing, keep an eye on the Debug Console in your extension development window for any error messages or logs that might help you identify issues.

Packaging and Publishing Your Extension

Once you're satisfied with your extension, you can package it for distribution or publish it to the VS Code Marketplace:


# Install vsce if you haven't already
npm install -g vsce

# Package your extension into a .vsix file
vsce package

# Publish to the marketplace (requires an Azure DevOps account)
vsce publish

Publishing to the VS Code Marketplace makes your extension available to millions of developers worldwide. Be sure to include:

  • A detailed README with usage instructions
  • Screenshots or GIFs demonstrating your extension in action
  • Links to the source code repository
  • A clear description of what your extension does and who it's for

Real-World Example: Custom React Component Debugger

To make this more concrete, let's look at a real-world example. Say we want to create a specialized debugger for React components that shows the component tree, props, and state in a more intuitive way than the standard debugger.

Here's a snapshot of what the specialized visualization might look like:


// Sample React component debugging visualization
function ReactComponentVisualizer(component) {
  return {
    name: component.type.name || 'AnonymousComponent',
    props: formatProps(component.props),
    state: component.state ? formatState(component.state) : null,
    children: Array.isArray(component.children) 
      ? component.children.map(child => ReactComponentVisualizer(child))
      : []
  };
}

// This would be rendered in the webview as an interactive tree

Conclusion

Building a custom VS Code extension for debugging is a powerful way to streamline your development workflow. While it requires an investment of time upfront, the productivity gains can be substantial, especially if you're working on complex projects with specific debugging needs.

The extension we've built in this tutorial provides a foundation that you can extend with features specific to your workflow. Whether you're debugging React applications, Node.js servers, or any other JavaScript/TypeScript code, having tools tailored to your needs can make a world of difference.

What debugging pain points would you like to solve with a custom extension? Have you already built extensions to improve your development experience? Share your thoughts and experiences in the comments below!

Resources

Comments

Leave a Comment

Comments are moderated before appearing

No comments yet. Be the first to comment!