Skip to main content

Building Debugger Extensions

Debugger extensions integrate Debug Adapter Protocol (DAP) servers into Glass, enabling step-through debugging, breakpoints, variable inspection, and more.

Debug Adapter Protocol

The Debug Adapter Protocol (DAP) is a standardized protocol for debuggers. Glass uses DAP to provide language-agnostic debugging capabilities. A debug adapter:
  • Communicates between Glass and the debugger
  • Handles launch and attach requests
  • Manages breakpoints and stepping
  • Provides variable and stack inspection

Defining Debug Adapters

1
Register the adapter
2
Add a debug adapter entry to extension.toml:
3
[debug_adapters.my-debugger]
# Optional: custom schema path
# Defaults to debug_adapter_schemas/my-debugger.json
schema_path = "schemas/debugger-config.json"
4
The schema file is mandatory and defines the JSON schema for debug configurations.
5
Create the configuration schema
6
Create debug_adapter_schemas/my-debugger.json:
7
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["request", "program"],
  "properties": {
    "request": {
      "type": "string",
      "enum": ["launch", "attach"]
    },
    "program": {
      "type": "string",
      "description": "Path to the program to debug"
    },
    "args": {
      "type": "array",
      "items": {"type": "string"},
      "description": "Command line arguments"
    },
    "cwd": {
      "type": "string",
      "description": "Working directory"
    },
    "env": {
      "type": "object",
      "description": "Environment variables"
    }
  }
}
8
Implement get_dap_binary
9
Return the debug adapter command:
10
use zed_extension_api::{
    self as zed, DebugAdapterBinary, DebugTaskDefinition,
    Worktree, Result,
};

struct MyDebuggerExtension {
    cached_adapter_path: Option<String>,
}

impl zed::Extension for MyDebuggerExtension {
    fn new() -> Self {
        Self {
            cached_adapter_path: None,
        }
    }

    fn get_dap_binary(
        &mut self,
        adapter_name: String,
        config: DebugTaskDefinition,
        user_provided_debug_adapter_path: Option<String>,
        worktree: &Worktree,
    ) -> Result<DebugAdapterBinary, String> {
        // Use user-provided path if available
        if let Some(path) = user_provided_debug_adapter_path {
            return Ok(DebugAdapterBinary {
                command: path,
                args: vec![],
                env: None,
            });
        }

        // Check if adapter is in PATH
        if let Some(path) = worktree.which("my-debugger") {
            return Ok(DebugAdapterBinary {
                command: path,
                args: vec![],
                env: None,
            });
        }

        // Download or install the adapter
        let adapter_path = self.install_adapter(&adapter_name)?;

        Ok(DebugAdapterBinary {
            command: adapter_path,
            args: vec![],
            env: None,
        })
    }
}

Request Kind Determination

Implement dap_request_kind to determine launch vs. attach:
use zed_extension_api::{
    StartDebuggingRequestArgumentsRequest,
    LaunchRequest, AttachRequest,
};
use serde_json::Value;

impl zed::Extension for MyDebuggerExtension {
    fn dap_request_kind(
        &mut self,
        _adapter_name: String,
        config: Value,
    ) -> Result<StartDebuggingRequestArgumentsRequest, String> {
        let request = config
            .get("request")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'request' field")?;

        match request {
            "launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch(
                LaunchRequest {
                    configuration: config,
                }
            )),
            "attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach(
                AttachRequest {
                    configuration: config,
                }
            )),
            _ => Err(format!("Unknown request type: {}", request)),
        }
    }
}

High-Level Debug Configuration

Implement dap_config_to_scenario to convert generic configs to adapter-specific ones:
use zed_extension_api::{DebugConfig, DebugScenario, DebugRequest};

impl zed::Extension for MyDebuggerExtension {
    fn dap_config_to_scenario(
        &mut self,
        config: DebugConfig,
    ) -> Result<DebugScenario, String> {
        // Convert generic config to adapter-specific config
        let adapter_config = serde_json::json!({
            "request": "launch",
            "program": config.program,
            "args": config.args,
            "cwd": config.cwd,
            "env": config.env,
        });

        Ok(DebugScenario {
            adapter_name: "my-debugger".to_string(),
            request: DebugRequest::Launch(LaunchRequest {
                configuration: adapter_config,
            }),
            build: None, // No build task needed
        })
    }
}

Installing Debug Adapters

From GitHub Releases

fn install_adapter(&mut self, adapter_name: &str) -> Result<String, String> {
    if let Some(path) = &self.cached_adapter_path {
        if std::fs::metadata(path).is_ok() {
            return Ok(path.clone());
        }
    }

    let release = zed::latest_github_release(
        "debugger-org/my-debugger",
        zed::GithubReleaseOptions {
            require_assets: true,
            pre_release: false,
        },
    )?;

    let (platform, arch) = zed::current_platform();
    let asset_name = format!(
        "my-debugger-{}-{}.tar.gz",
        platform_str(platform),
        arch_str(arch)
    );

    let asset = release
        .assets
        .iter()
        .find(|a| a.name == asset_name)
        .ok_or_else(|| format!("No asset found: {}", asset_name))?;

    let version_dir = format!("my-debugger-{}", release.version);
    zed::download_file(
        &asset.download_url,
        &version_dir,
        zed::DownloadedFileType::GzipTar,
    )?;

    let binary_path = format!("{}/my-debugger", version_dir);
    zed::make_file_executable(&binary_path)?;

    self.cached_adapter_path = Some(binary_path.clone());
    Ok(binary_path)
}

fn platform_str(platform: zed::Os) -> &'static str {
    match platform {
        zed::Os::Mac => "darwin",
        zed::Os::Linux => "linux",
        zed::Os::Windows => "windows",
    }
}

fn arch_str(arch: zed::Architecture) -> &'static str {
    match arch {
        zed::Architecture::Aarch64 => "arm64",
        zed::Architecture::X8664 => "x64",
        zed::Architecture::X86 => "x86",
    }
}

From npm

fn install_node_adapter(&mut self) -> Result<String, String> {
    const PACKAGE_NAME: &str = "@company/debugger";
    const ADAPTER_PATH: &str = "node_modules/@company/debugger/out/debugAdapter.js";

    let version = zed::npm_package_latest_version(PACKAGE_NAME)?;

    if !std::fs::metadata(ADAPTER_PATH).is_ok()
        || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version)
    {
        zed::npm_install_package(PACKAGE_NAME, &version)?;
    }

    Ok(ADAPTER_PATH.to_string())
}

Debug Locators

Locators automatically convert tasks (like cargo run) into debug scenarios.

Registering a Locator

Add to extension.toml:
[debug_locators.my-locator]

Two-Phase Resolution

Locators work in two phases:
  1. Create scenario - Convert task to debug scenario with optional build task
  2. Run locator - After build, determine final debug configuration
impl zed::Extension for MyDebuggerExtension {
    fn dap_locator_create_scenario(
        &mut self,
        locator_name: String,
        build_task: TaskTemplate,
        resolved_label: String,
        debug_adapter_name: String,
    ) -> Option<DebugScenario> {
        // Check if this task is relevant
        if !build_task.command.starts_with("cargo run") {
            return None;
        }

        // Convert "cargo run" to "cargo build"
        let mut build_task = build_task.clone();
        build_task.command = build_task.command.replace("run", "build");

        Some(DebugScenario {
            adapter_name: debug_adapter_name,
            request: DebugRequest::Launch(LaunchRequest {
                configuration: serde_json::json!({}),
            }),
            build: Some(BuildTaskDefinition {
                task_template: build_task,
            }),
        })
    }

    fn run_dap_locator(
        &mut self,
        _locator_name: String,
        build_task: TaskTemplate,
    ) -> Result<DebugRequest, String> {
        // After build completes, find the binary
        let binary = find_built_binary(&build_task)?;

        Ok(DebugRequest::Launch(LaunchRequest {
            configuration: serde_json::json!({
                "request": "launch",
                "program": binary,
            }),
        }))
    }
}
You can skip the two-phase resolution if you can determine the full configuration in dap_locator_create_scenario. Just omit the build field.

Example: Complete Debugger Extension

id = "rust-debugger"
name = "Rust Debugger"
version = "0.1.0"
schema_version = 1
authors = ["Your Name <[email protected]>"]
description = "LLDB debugger for Rust"
repository = "https://github.com/you/rust-debugger"

[debug_adapters.lldb]
schema_path = "debug_adapter_schemas/lldb.json"

[debug_locators.cargo]

Testing Debugger Extensions

1
Install as dev extension
2
Press Cmd+Shift+P and run Extensions: Install Dev Extension.
3
Create a debug configuration
4
Create .glass/debug.json in your project:
5
{
  "configurations": [
    {
      "type": "lldb",
      "request": "launch",
      "name": "Debug Rust",
      "program": "${workspaceFolder}/target/debug/myapp",
      "args": [],
      "cwd": "${workspaceFolder}"
    }
  ]
}
6
Start debugging
7
Press F5 or run Debug: Start from the command palette.

See Also

Build docs developers (and LLMs) love