diff --git a/agents/docs/commands-reference.md b/agents/docs/commands-reference.md index 523cfbdc..a7843986 100644 --- a/agents/docs/commands-reference.md +++ b/agents/docs/commands-reference.md @@ -35,7 +35,7 @@ help text). | `fbuild iwyu` | Run `include-what-you-use` analysis. | `fbuild help iwyu` | | `fbuild clang-query` | Run a clang-query matcher script over the project. | `fbuild help clang-query` | | `fbuild clangd-config` | Emit `.clangd` / `.vscode/settings.json` for the default env. | `fbuild help clangd-config` | -| `fbuild ide` / `fbuild ide select` | You want to open a project as an IDE workspace on stock Zed: installs declared deps, refreshes the compile DB, emits `.clangd` + `.zed/settings.json` + `.zed/tasks.json`, launches Zed. `ide select` interactively (or via `-e`) switches the persisted environment and regenerates. | `fbuild help ide`, FastLED/fbuild#1076 Phase 1, `docs/reference/cli.md#fbuild-ide` | +| `fbuild ide` / `fbuild ide select` | You want to open a project as an IDE workspace on stock Zed: installs declared deps, refreshes the compile DB, emits `.clangd` + `.zed/settings.json` + `.zed/tasks.json`, and — for probe-rs-supported boards only (RP2040/RP2350, a small ARM Cortex-M set) — `.zed/debug.json` plus a `probe-rs dap-server` task, then launches Zed. Unsupported targets (ESP32, AVR) get a one-line "not supported" note, not a failure. `ide select` interactively (or via `-e`) switches the persisted environment and regenerates. | `fbuild help ide`, FastLED/fbuild#1076 Phase 1 & Phase 3 milestone 1, `docs/reference/cli.md#fbuild-ide` | | `fbuild lib-select` | Drive the LDF-style library-selection resolver and print the selected library set. Use this when debugging "library not found" without a full build. | FastLED/fbuild#202 / #204 | ## Daemon & cache diff --git a/crates/fbuild-cli/src/cli/ide.rs b/crates/fbuild-cli/src/cli/ide.rs index 045dfd7c..f94c7e23 100644 --- a/crates/fbuild-cli/src/cli/ide.rs +++ b/crates/fbuild-cli/src/cli/ide.rs @@ -25,6 +25,7 @@ use crate::output; use super::build::normalize_path; use super::clangd_config::{Editor, emit_clangd_file, emit_editor_config, ensure_compile_db}; +use super::ide_debug::{self, PROBE_RS_DAP_PORT, probe_rs_chip_for_mcu, unsupported_debug_note}; /// Label prefix that marks a Zed task as fbuild-owned. Merge logic replaces /// every task with this prefix and leaves everything else untouched. @@ -114,27 +115,64 @@ struct ZedTask { args: Vec, } -/// The fbuild-owned tasks for a given environment. All are plain `fbuild` -/// invocations so they render in Zed's terminal panel with clickable -/// `file:line` diagnostics, same as running them by hand. -fn build_fbuild_tasks(env_name: &str) -> Vec { - let task = |label: &str, args: Vec<&str>| ZedTask { +/// The fbuild-owned tasks for a given environment. All but the debug-server +/// task are plain `fbuild` invocations so they render in Zed's terminal +/// panel with clickable `file:line` diagnostics, same as running them by +/// hand. +/// +/// `debug_chip`, when `Some` (i.e. [`probe_rs_chip_for_mcu`] resolved a +/// probe-rs chip for the current environment — FastLED/fbuild#1076 Phase +/// 3), adds a task that runs `probe-rs dap-server` on +/// [`PROBE_RS_DAP_PORT`], which `.zed/debug.json`'s attach entry connects +/// to. fbuild does not install probe-rs itself in milestone 1 — if it's +/// missing, the task fails in Zed's terminal panel with probe-rs's own +/// "command not found" message. +fn build_fbuild_tasks(env_name: &str, debug_chip: Option<&str>) -> Vec { + let task = |label: &str, command: &str, args: Vec| ZedTask { label: format!("{FBUILD_TASK_PREFIX}{label}"), - command: "fbuild".to_string(), - args: args.into_iter().map(str::to_string).collect(), + command: command.to_string(), + args, }; - vec![ - task("Build", vec!["build", "-e", env_name]), - task("Build (clean)", vec!["build", "-e", env_name, "--clean"]), - task("Deploy", vec!["deploy", "-e", env_name]), + let str_args = |args: Vec<&str>| args.into_iter().map(str::to_string).collect::>(); + let mut tasks = vec![ + task("Build", "fbuild", str_args(vec!["build", "-e", env_name])), + task( + "Build (clean)", + "fbuild", + str_args(vec!["build", "-e", env_name, "--clean"]), + ), + task("Deploy", "fbuild", str_args(vec!["deploy", "-e", env_name])), task( "Deploy + Monitor", - vec!["deploy", "-e", env_name, "--monitor"], + "fbuild", + str_args(vec!["deploy", "-e", env_name, "--monitor"]), + ), + task( + "Monitor", + "fbuild", + str_args(vec!["monitor", "-e", env_name]), ), - task("Monitor", vec!["monitor", "-e", env_name]), - task("Reset", vec!["reset", "-e", env_name]), - task("Select environment", vec!["ide", "select"]), - ] + task("Reset", "fbuild", str_args(vec!["reset", "-e", env_name])), + task( + "Select environment", + "fbuild", + str_args(vec!["ide", "select"]), + ), + ]; + if let Some(chip) = debug_chip { + tasks.push(task( + "Debug server (probe-rs)", + "probe-rs", + vec![ + "dap-server".to_string(), + "--port".to_string(), + PROBE_RS_DAP_PORT.to_string(), + "--chip".to_string(), + chip.to_string(), + ], + )); + } + tasks } /// Merge fbuild's tasks into `.zed/tasks.json`: any existing task whose @@ -163,14 +201,18 @@ fn read_tasks_file(path: &Path) -> Vec { /// Write `.zed/tasks.json` with fbuild's tasks merged in, preserving any /// user-authored tasks. Returns the path written. -fn emit_zed_tasks(project_path: &Path, env_name: &str) -> fbuild_core::Result { +fn emit_zed_tasks( + project_path: &Path, + env_name: &str, + debug_chip: Option<&str>, +) -> fbuild_core::Result { let zed_dir = project_path.join(".zed"); std::fs::create_dir_all(&zed_dir).map_err(|e| { fbuild_core::FbuildError::Other(format!("failed to create {}: {}", zed_dir.display(), e)) })?; let tasks_path = zed_dir.join("tasks.json"); let existing = read_tasks_file(&tasks_path); - let merged = merge_tasks(&existing, &build_fbuild_tasks(env_name)); + let merged = merge_tasks(&existing, &build_fbuild_tasks(env_name, debug_chip)); let mut json = serde_json::to_string_pretty(&merged).map_err(|e| { fbuild_core::FbuildError::Other(format!("failed to serialize tasks.json: {}", e)) })?; @@ -259,6 +301,57 @@ fn launch_zed(zed_path: &Path, project_dir: &str) -> fbuild_core::Result<()> { Ok(()) } +// --------------------------------------------------------------------- +// debug.json (FastLED/fbuild#1076 Phase 3, milestone 1: probe-rs targets) +// --------------------------------------------------------------------- + +/// Resolve the `BoardConfig::mcu` string for `env_name`, the same way +/// `deploy::infer_cli_default_emulator_kind` resolves a board for emulator +/// hints: `platform =` / `board =` from `platformio.ini`, plus any +/// `board_build.*`/`board_upload.*` overrides, resolved against the +/// built-in board DB (falling back to `/boards/.json`). +/// Best-effort — returns `Ok(None)` (never an error) when the environment +/// has no resolvable board, since a missing board just means "no debug +/// config", not a hard failure. +fn resolve_mcu_for_env(project_path: &Path, env_name: &str) -> fbuild_core::Result> { + let ini_path = project_path.join("platformio.ini"); + let config = fbuild_config::PlatformIOConfig::from_path(&ini_path)?; + let Ok(env_config) = config.get_env_config(env_name) else { + return Ok(None); + }; + let Some(board_id) = env_config.get("board") else { + return Ok(None); + }; + let board_overrides = config.get_board_overrides(env_name).unwrap_or_default(); + let board = fbuild_config::BoardConfig::from_board_id_with_override_fallback( + board_id, + &board_overrides, + Some(project_path), + ); + Ok(board.map(|b| b.mcu)) +} + +/// Resolve the probe-rs chip (if any) for `env_name` and, when found, emit +/// `.zed/debug.json`. Returns `(chip, debug_json_path)` — `chip` feeds the +/// `.zed/tasks.json` debug-server task, `debug_json_path` feeds the +/// generated-files summary. When the environment's board/MCU isn't a +/// probe-rs target, prints the one-line unsupported note and returns +/// `(None, None)` — this is a first-class outcome, not an error. +fn regenerate_debug_config( + project_path: &Path, + env_name: &str, +) -> fbuild_core::Result<(Option, Option)> { + let mcu = resolve_mcu_for_env(project_path, env_name)?; + let Some(chip) = mcu.as_deref().and_then(probe_rs_chip_for_mcu) else { + let label = mcu.as_deref().unwrap_or(env_name); + output::result(unsupported_debug_note(label)); + return Ok((None, None)); + }; + let elf_path = ide_debug::expected_elf_path(project_path, env_name); + let debug_path = ide_debug::emit_zed_debug(project_path, env_name, Some(&elf_path))?; + Ok((Some(chip.to_string()), Some(debug_path))) +} + // --------------------------------------------------------------------- // daemon-backed steps // --------------------------------------------------------------------- @@ -303,7 +396,16 @@ async fn regenerate_ide_config( for (path, _written) in emit_editor_config(Editor::Zed, project_path)? { written.push(path); } - written.push(emit_zed_tasks(project_path, env_name)?); + + let (debug_chip, debug_path) = regenerate_debug_config(project_path, env_name)?; + if let Some(debug_path) = debug_path { + written.push(debug_path); + } + written.push(emit_zed_tasks( + project_path, + env_name, + debug_chip.as_deref(), + )?); Ok(written) } @@ -507,11 +609,80 @@ mod tests { assert!(resolve_ide_env(tmp.path(), None).is_err()); } + // ---------- mcu resolution + debug config (FastLED/fbuild#1076 Phase 3) ---------- + + #[test] + fn resolve_mcu_for_env_finds_rp2040_for_rpipico_board() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:pico]\nplatform = raspberrypi\nboard = rpipico\n", + ) + .unwrap(); + let mcu = resolve_mcu_for_env(tmp.path(), "pico").unwrap(); + assert_eq!(mcu.as_deref(), Some("rp2040")); + } + + #[test] + fn resolve_mcu_for_env_finds_atmega328p_for_uno_board() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:uno]\nplatform = atmelavr\nboard = uno\n", + ) + .unwrap(); + let mcu = resolve_mcu_for_env(tmp.path(), "uno").unwrap(); + assert_eq!(mcu.as_deref(), Some("atmega328p")); + } + + #[test] + fn resolve_mcu_for_env_none_for_missing_env() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:uno]\nplatform = atmelavr\nboard = uno\n", + ) + .unwrap(); + let mcu = resolve_mcu_for_env(tmp.path(), "not_an_env").unwrap(); + assert_eq!(mcu, None); + } + + #[test] + fn regenerate_debug_config_emits_debug_json_for_rp2040() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:pico]\nplatform = raspberrypi\nboard = rpipico\n", + ) + .unwrap(); + let (chip, debug_path) = regenerate_debug_config(tmp.path(), "pico").unwrap(); + assert_eq!(chip.as_deref(), Some("RP2040")); + let debug_path = debug_path.expect("debug.json should be written"); + assert!(debug_path.exists()); + let content = std::fs::read_to_string(&debug_path).unwrap(); + assert!(content.contains("probe-rs")); + assert!(content.contains("50101")); + } + + #[test] + fn regenerate_debug_config_no_debug_json_for_avr() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:uno]\nplatform = atmelavr\nboard = uno\n", + ) + .unwrap(); + let (chip, debug_path) = regenerate_debug_config(tmp.path(), "uno").unwrap(); + assert_eq!(chip, None); + assert_eq!(debug_path, None); + assert!(!tmp.path().join(".zed/debug.json").exists()); + } + // ---------- tasks.json merge ---------- #[test] fn build_fbuild_tasks_pins_environment_in_args() { - let tasks = build_fbuild_tasks("esp32dev"); + let tasks = build_fbuild_tasks("esp32dev", None); assert_eq!(tasks.len(), 7); for label in [ "fbuild: Build", @@ -534,6 +705,23 @@ mod tests { .find(|t| t.label == "fbuild: Select environment") .unwrap(); assert_eq!(select.args, vec!["ide", "select"]); + // No debug-chip resolved -> no debug-server task. + assert!(!tasks.iter().any(|t| t.label.contains("Debug server"))); + } + + #[test] + fn build_fbuild_tasks_adds_debug_server_task_when_chip_resolved() { + let tasks = build_fbuild_tasks("rpipico", Some("RP2040")); + assert_eq!(tasks.len(), 8); + let debug = tasks + .iter() + .find(|t| t.label == "fbuild: Debug server (probe-rs)") + .unwrap(); + assert_eq!(debug.command, "probe-rs"); + assert_eq!( + debug.args, + vec!["dap-server", "--port", "50101", "--chip", "RP2040"] + ); } #[test] @@ -550,7 +738,7 @@ mod tests { args: vec!["build".to_string(), "-e".to_string(), "stale".to_string()], }, ]; - let fresh = build_fbuild_tasks("esp32dev"); + let fresh = build_fbuild_tasks("esp32dev", None); let merged = merge_tasks(&existing, &fresh); assert!(merged.iter().any(|t| t.label == "My custom task")); @@ -565,7 +753,7 @@ mod tests { #[test] fn merge_tasks_is_idempotent() { - let fresh = build_fbuild_tasks("esp32dev"); + let fresh = build_fbuild_tasks("esp32dev", None); let once = merge_tasks(&[], &fresh); let twice = merge_tasks(&once, &fresh); assert_eq!(once, twice); @@ -582,7 +770,7 @@ mod tests { ) .unwrap(); - let path = emit_zed_tasks(tmp.path(), "esp32dev").unwrap(); + let path = emit_zed_tasks(tmp.path(), "esp32dev", None).unwrap(); let content = std::fs::read_to_string(path).unwrap(); let tasks: Vec = serde_json::from_str(&content).unwrap(); assert!(tasks.iter().any(|t| t.label == "My custom task")); diff --git a/crates/fbuild-cli/src/cli/ide_debug.rs b/crates/fbuild-cli/src/cli/ide_debug.rs new file mode 100644 index 00000000..5959be48 --- /dev/null +++ b/crates/fbuild-cli/src/cli/ide_debug.rs @@ -0,0 +1,336 @@ +//! `.zed/debug.json` generation for `fbuild ide` (FastLED/fbuild#1076 Phase +//! 3, milestone 1: probe-rs-supported targets only). +//! +//! Zed's debugger is DAP-native and supports attaching to an +//! already-running external DAP server via a `tcp_connection` entry in +//! `.zed/debug.json`. probe-rs ships exactly such a server +//! (`probe-rs dap-server`, the same one its VS Code extension drives), so +//! for chips probe-rs supports we can generate a working attach config with +//! zero Zed extension involvement. +//! +//! Milestone 1 is deliberately narrow: **ARM Cortex-M families + RP2040** +//! only. OpenOCD-based targets (ESP32 via openocd-esp32, AVR) are out of +//! scope — probe-rs doesn't support them, and driving OpenOCD would need a +//! DAP<->GDB bridge Zed has no equivalent of. For those targets we emit +//! nothing and surface a first-class "not supported yet" note instead of +//! silently doing nothing or failing. +//! +//! ## Chip mapping +//! +//! [`probe_rs_chip_for_mcu`] maps fbuild's `BoardConfig::mcu` string (e.g. +//! `"rp2040"`, `"stm32f103c8t6"`, as emitted by the board JSON database +//! under `crates/fbuild-config/assets/boards/json/`) to a probe-rs chip +//! identifier (the `--chip` argument to `probe-rs`/`probe-rs dap-server`). +//! The table is intentionally short: a wrong chip name silently produces a +//! debug session that can't find the target (or worse, targets the wrong +//! silicon), which is worse than no entry at all. Unmapped/unknown MCUs +//! return `None` rather than guessing. +//! +//! | fbuild `mcu` | probe-rs chip | confidence | +//! |---|---|---| +//! | `rp2040` | `RP2040` | high — probe-rs's flagship supported target | +//! | `rp2350` | `RP2350` | medium — probe-rs added RP2350 support; exact variant suffix (`RP2350A`/`RP2350B`) not distinguished by fbuild's board data, so this uses the bare family name | +//! | `nrf52840` | `nRF52840_xxAA` | high — standard probe-rs-target naming for Nordic parts (package-suffixed SVD-derived name) | +//! | `stm32f103c8t6` | `STM32F103C8` | medium — common "Blue Pill" chip; probe-rs-target STM32 names track the bare part number (no package/temperature suffix) for this family | +//! +//! Deliberately **not** mapped even though fbuild has board data for them: +//! Teensy 4.x (`imxrt1062` — unclear whether probe-rs's iMX RT target name +//! is `MIMXRT1062xxxxA` or a variant-specific string; Teensy also has no +//! standard SWD debug header wired to CMSIS-DAP/J-Link out of the box), any +//! other STM32 family/package not in the table above, and anything AVR or +//! ESP32 (not probe-rs targets at all). + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Fixed default port `fbuild ide` tells `probe-rs dap-server` to listen on +/// and tells Zed to attach to. Documented in `docs/reference/cli.md`. +pub(crate) const PROBE_RS_DAP_PORT: u16 = 50101; + +/// Label prefix that marks a Zed debug entry as fbuild-owned, mirroring +/// `ide::FBUILD_TASK_PREFIX` for `.zed/tasks.json`. +const FBUILD_DEBUG_PREFIX: &str = "fbuild: "; + +/// Map an fbuild `BoardConfig::mcu` string to a probe-rs chip identifier. +/// Case-insensitive on the input (board JSON is lowercase today, but this +/// doesn't assume it stays that way). Returns `None` for anything not in +/// the conservative table documented on this module. +pub(crate) fn probe_rs_chip_for_mcu(mcu: &str) -> Option<&'static str> { + match mcu.to_ascii_lowercase().as_str() { + "rp2040" => Some("RP2040"), + "rp2350" => Some("RP2350"), + "nrf52840" => Some("nRF52840_xxAA"), + "stm32f103c8t6" | "stm32f103c8" => Some("STM32F103C8"), + _ => None, + } +} + +/// The concise, first-class "not supported yet" note printed during +/// `fbuild ide` for boards/MCUs milestone 1 doesn't cover. Pure so it's +/// directly testable without stdout capture. +pub(crate) fn unsupported_debug_note(board_or_mcu: &str) -> String { + format!("debug config not supported for {board_or_mcu} (milestone 1 is probe-rs targets)") +} + +// --------------------------------------------------------------------- +// .zed/debug.json — merge-don't-clobber +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ZedTcpConnection { + host: String, + port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ZedDebugEntry { + label: String, + adapter: String, + request: String, + #[serde(skip_serializing_if = "Option::is_none")] + program: Option, + tcp_connection: ZedTcpConnection, +} + +/// Build the fbuild-owned debug entry. `env_name` isn't folded into the +/// label — like `.zed/tasks.json`'s "fbuild: Build" task, the label stays +/// unqualified by environment because milestone 1 only ever configures the +/// one currently-selected environment (`fbuild ide select`) at a time; the +/// entry's contents (chip via the running `probe-rs dap-server`, `program`) +/// are what actually change per environment. `elf_path`, when resolvable, is +/// passed through as `program` so probe-rs can load symbols; the path does +/// not need to exist yet at config-generation time (it's the expected +/// `fbuild build -e ` output location). +fn build_debug_entry(_env_name: &str, elf_path: Option<&Path>) -> ZedDebugEntry { + ZedDebugEntry { + label: format!("{FBUILD_DEBUG_PREFIX}Debug (probe-rs attach)"), + adapter: "probe-rs".to_string(), + request: "attach".to_string(), + program: elf_path.map(|p| p.display().to_string()), + tcp_connection: ZedTcpConnection { + host: "127.0.0.1".to_string(), + port: PROBE_RS_DAP_PORT, + }, + } +} + +/// Merge fbuild's debug entry into existing `.zed/debug.json` content: any +/// existing entry whose label starts with `"fbuild: "` is replaced; every +/// other (user) entry is preserved verbatim, in its original position. +fn merge_debug_entries( + existing: &[ZedDebugEntry], + fbuild_entries: &[ZedDebugEntry], +) -> Vec { + let mut merged: Vec = existing + .iter() + .filter(|e| !e.label.starts_with(FBUILD_DEBUG_PREFIX)) + .cloned() + .collect(); + merged.extend(fbuild_entries.iter().cloned()); + merged +} + +fn read_debug_file(path: &Path) -> Vec { + let Ok(content) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + if content.trim().is_empty() { + return Vec::new(); + } + serde_json::from_str(&content).unwrap_or_default() +} + +/// Write `.zed/debug.json` with fbuild's probe-rs attach entry merged in, +/// preserving any user-authored entries. Only called when +/// [`probe_rs_chip_for_mcu`] resolved a chip for the current environment. +pub(crate) fn emit_zed_debug( + project_path: &Path, + env_name: &str, + elf_path: Option<&Path>, +) -> fbuild_core::Result { + let zed_dir = project_path.join(".zed"); + std::fs::create_dir_all(&zed_dir).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to create {}: {}", zed_dir.display(), e)) + })?; + let debug_path = zed_dir.join("debug.json"); + let existing = read_debug_file(&debug_path); + let fresh = vec![build_debug_entry(env_name, elf_path)]; + let merged = merge_debug_entries(&existing, &fresh); + let mut json = serde_json::to_string_pretty(&merged).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to serialize debug.json: {}", e)) + })?; + json.push('\n'); + std::fs::write(&debug_path, json).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to write {}: {}", debug_path.display(), e)) + })?; + Ok(debug_path) +} + +/// The expected ELF output path for `env_name`, best-effort: `fbuild +/// build`'s release-profile layout. Doesn't check existence — it's a +/// placeholder for the user to have built once before attaching, same as +/// any DAP `program` field. +pub(crate) fn expected_elf_path(project_path: &Path, env_name: &str) -> PathBuf { + fbuild_paths::BuildLayout::new( + project_path.to_path_buf(), + env_name.to_string(), + fbuild_core::BuildProfile::Release, + ) + .resolve() + .join("firmware.elf") +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---------- chip mapping ---------- + + #[test] + fn maps_known_probe_rs_chips() { + assert_eq!(probe_rs_chip_for_mcu("rp2040"), Some("RP2040")); + assert_eq!(probe_rs_chip_for_mcu("RP2040"), Some("RP2040")); + assert_eq!(probe_rs_chip_for_mcu("rp2350"), Some("RP2350")); + assert_eq!(probe_rs_chip_for_mcu("nrf52840"), Some("nRF52840_xxAA")); + assert_eq!(probe_rs_chip_for_mcu("stm32f103c8t6"), Some("STM32F103C8")); + assert_eq!(probe_rs_chip_for_mcu("stm32f103c8"), Some("STM32F103C8")); + } + + #[test] + fn unmapped_mcus_return_none() { + for mcu in [ + "atmega328p", + "atmega2560", + "esp32", + "esp32s3", + "esp8266", + "imxrt1062", // Teensy 4.x — deliberately left out, see module docs + "attiny85", + "", + "totally-unknown-chip", + ] { + assert_eq!(probe_rs_chip_for_mcu(mcu), None, "mcu={mcu}"); + } + } + + // ---------- unsupported note ---------- + + #[test] + fn unsupported_note_names_the_target_and_milestone() { + let note = unsupported_debug_note("esp32dev"); + assert!(note.contains("esp32dev")); + assert!(note.contains("not supported")); + assert!(note.contains("probe-rs")); + } + + // ---------- debug.json merge ---------- + + #[test] + fn merge_preserves_user_entries_and_replaces_fbuild_owned() { + let existing = vec![ + ZedDebugEntry { + label: "My custom debug config".to_string(), + adapter: "CodeLLDB".to_string(), + request: "launch".to_string(), + program: Some("/some/path".to_string()), + tcp_connection: ZedTcpConnection { + host: "127.0.0.1".to_string(), + port: 1234, + }, + }, + ZedDebugEntry { + label: "fbuild: Debug (probe-rs attach)".to_string(), + adapter: "probe-rs".to_string(), + request: "attach".to_string(), + program: None, + tcp_connection: ZedTcpConnection { + host: "127.0.0.1".to_string(), + port: 9999, // stale port + }, + }, + ]; + let fresh = vec![build_debug_entry("esp32dev", None)]; + let merged = merge_debug_entries(&existing, &fresh); + + assert!(merged.iter().any(|e| e.label == "My custom debug config")); + let ours = merged + .iter() + .find(|e| e.label == "fbuild: Debug (probe-rs attach)") + .unwrap(); + assert_eq!(ours.tcp_connection.port, PROBE_RS_DAP_PORT); + assert_eq!( + merged + .iter() + .filter(|e| e.label == "fbuild: Debug (probe-rs attach)") + .count(), + 1 + ); + } + + #[test] + fn merge_is_idempotent() { + let fresh = vec![build_debug_entry("esp32dev", None)]; + let once = merge_debug_entries(&[], &fresh); + let twice = merge_debug_entries(&once, &fresh); + assert_eq!(once, twice); + } + + #[test] + fn emit_zed_debug_preserves_user_entry_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let zed_dir = tmp.path().join(".zed"); + std::fs::create_dir_all(&zed_dir).unwrap(); + std::fs::write( + zed_dir.join("debug.json"), + r#"[{"label": "My custom debug config", "adapter": "CodeLLDB", "request": "launch", "tcp_connection": {"host": "127.0.0.1", "port": 1}}]"#, + ) + .unwrap(); + + let path = emit_zed_debug(tmp.path(), "esp32dev", None).unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + let entries: Vec = serde_json::from_str(&content).unwrap(); + assert!(entries.iter().any(|e| e.label == "My custom debug config")); + assert!( + entries + .iter() + .any(|e| e.label == "fbuild: Debug (probe-rs attach)") + ); + } + + #[test] + fn emit_zed_debug_is_idempotent_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let first = emit_zed_debug(tmp.path(), "esp32dev", None).unwrap(); + let first_content = std::fs::read_to_string(&first).unwrap(); + let second = emit_zed_debug(tmp.path(), "esp32dev", None).unwrap(); + let second_content = std::fs::read_to_string(&second).unwrap(); + assert_eq!(first_content, second_content); + } + + #[test] + fn build_debug_entry_includes_elf_program_when_given() { + let elf = Path::new("/proj/.fbuild/build/esp32dev/release/firmware.elf"); + let entry = build_debug_entry("esp32dev", Some(elf)); + assert_eq!( + entry.program.as_deref(), + Some("/proj/.fbuild/build/esp32dev/release/firmware.elf") + ); + } + + #[test] + fn build_debug_entry_omits_program_when_unresolvable() { + let entry = build_debug_entry("esp32dev", None); + assert_eq!(entry.program, None); + } + + // ---------- expected elf path ---------- + + #[test] + fn expected_elf_path_points_at_release_firmware() { + let tmp = tempfile::tempdir().unwrap(); + let path = expected_elf_path(tmp.path(), "rpipico"); + assert!(path.ends_with("firmware.elf")); + assert!(path.to_string_lossy().contains("rpipico")); + } +} diff --git a/crates/fbuild-cli/src/cli/mod.rs b/crates/fbuild-cli/src/cli/mod.rs index 9bc019c8..14f5f6af 100644 --- a/crates/fbuild-cli/src/cli/mod.rs +++ b/crates/fbuild-cli/src/cli/mod.rs @@ -24,6 +24,7 @@ pub mod device; pub mod dispatch; pub mod graph_cmd; pub mod ide; +pub mod ide_debug; pub mod lnk; pub mod monitor_parse; pub mod pio; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 66e30c93..c712ebba 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -194,6 +194,9 @@ Generated/updated files: is left untouched. Safe to commit. - `.fbuild/ide_state.json` — the persisted environment choice. Local developer state; recommend `.gitignore`. +- `.zed/debug.json` — merge-don't-clobber, **only written when the + environment's board resolves to a probe-rs-supported chip** (see + "Debugging" below). Safe to commit. If `zed` isn't found on `PATH` or in the usual per-OS install locations, `fbuild ide` still generates/refreshes every file above and exits @@ -202,6 +205,42 @@ successfully — it just prints install guidance (`winget install Zed.Zed`, launching the editor. Config generation is the product; launching Zed is a convenience on top of it. +#### Debugging (FastLED/fbuild#1076 Phase 3, milestone 1) + +`fbuild ide` also tries to wire up Zed's debugger for the current +environment. **Milestone 1 covers probe-rs-supported targets only** — RP2040 +(and, best-effort, RP2350), and a small, deliberately conservative set of +ARM Cortex-M chips (currently: nRF52840 and the STM32F103C8 "Blue Pill"). +ESP32 and AVR are **not** probe-rs targets (ESP32 debugging goes through +OpenOCD, which speaks GDB-remote, not DAP; AVR has no comparable open +debug-adapter story) and are explicitly out of scope for this milestone — +for those environments `fbuild ide` prints a one-line note (`debug config +not supported for (milestone 1 is probe-rs targets)`) and moves +on. This is a normal, expected outcome, not a failure. + +When the environment's board resolves to a mapped chip, `fbuild ide`: + +- Writes `.zed/debug.json` with an fbuild-owned entry + (`"fbuild: Debug (probe-rs attach)"`) that attaches Zed's debugger to a + TCP DAP server at `127.0.0.1:50101` — the same merge-don't-clobber + ownership convention as `.zed/tasks.json` (fbuild only ever touches + entries whose label starts with `"fbuild: "`). +- Adds a `"fbuild: Debug server (probe-rs)"` task to `.zed/tasks.json` that + runs `probe-rs dap-server --port 50101 --chip `. Run this task + first (it's a long-lived server), then start the "Debug (probe-rs + attach)" debug config to connect. + +fbuild does **not** install `probe-rs` itself in milestone 1 — install it +yourself (`cargo install probe-rs-tools`, see +). If it's missing, +the debug-server task fails in Zed's terminal panel with probe-rs's own +"command not found" message. + +Port `50101` is fixed today (not yet configurable via a flag). The +`program` field in the generated debug entry points at the expected +`fbuild build -e ` ELF output location, whether or not it exists yet — +build once before attaching. + ## Batch And CI Commands ### `fbuild compile-many`