From 56add1b574586060a385728e0318a416d36f1784 Mon Sep 17 00:00:00 2001 From: zackees Date: Mon, 27 Jul 2026 13:24:34 -0700 Subject: [PATCH] feat(ide): add fbuild ide MVP on stock Zed (#1076 Phase 1) - `fbuild ide [dir] [-e env] [--no-launch]`: resolves the environment (explicit > persisted .fbuild/ide_state.json > default), installs declared deps via the daemon, refreshes compile_commands.json, emits .clangd + .zed/settings.json via the Phase 0 core, generates .zed/tasks.json (merge-don't-clobber: only "fbuild: "-prefixed task labels are owned; user tasks preserved), and launches Zed (PATH + known install dirs; friendly install guidance when absent, configs still generated). - `fbuild ide select`: interactive environment picker (stderr/stdin, -e bypass), persists the choice and regenerates configs. - daemon_client: new install_deps POST helper + InstallDepsRequest. Part of #1076 (Phase 1). Co-Authored-By: Claude Fable 5 --- agents/docs/commands-reference.md | 1 + crates/fbuild-cli/src/cli/args.rs | 35 ++ crates/fbuild-cli/src/cli/dispatch.rs | 22 +- crates/fbuild-cli/src/cli/ide.rs | 625 +++++++++++++++++++ crates/fbuild-cli/src/cli/mod.rs | 1 + crates/fbuild-cli/src/cli/tests.rs | 101 ++- crates/fbuild-cli/src/daemon_client.rs | 12 + crates/fbuild-cli/src/daemon_client/types.rs | 15 + docs/reference/cli.md | 46 ++ 9 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 crates/fbuild-cli/src/cli/ide.rs diff --git a/agents/docs/commands-reference.md b/agents/docs/commands-reference.md index d859bf60..523cfbdc 100644 --- a/agents/docs/commands-reference.md +++ b/agents/docs/commands-reference.md @@ -35,6 +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 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/args.rs b/crates/fbuild-cli/src/cli/args.rs index 6945bdf1..627cdc2e 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -510,6 +510,20 @@ pub enum Commands { #[arg(long)] refresh: bool, }, + /// Open (or configure) this project as an IDE workspace on stock Zed: + /// installs declared deps, refreshes the clangd compile database, emits + /// `.clangd` / `.zed/settings.json` / `.zed/tasks.json`, then launches + /// Zed (FastLED/fbuild#1076 Phase 1) + Ide { + project_dir: Option, + #[arg(short = 'e', long)] + environment: Option, + /// Generate/refresh IDE config but don't launch the Zed process. + #[arg(long)] + no_launch: bool, + #[command(subcommand)] + action: Option, + }, /// Build firmware and run it in an emulator for testing TestEmu { project_dir: Option, @@ -936,6 +950,26 @@ pub enum BloatCmd { }, } +/// `fbuild ide ` — today only `select` (interactive environment +/// picker). Nested under `Commands::Ide` alongside its own `project_dir` / +/// `environment` / `no_launch` args: when the first token after `ide` +/// doesn't match a known action name, clap falls back to treating it as +/// `Commands::Ide`'s own `project_dir` positional (FastLED/fbuild#1076 +/// Phase 1). +#[derive(Subcommand, Debug)] +pub enum IdeAction { + /// Interactively choose (and persist) the PlatformIO environment used + /// for this project's IDE config, then regenerate the compile database + /// and `.clangd` / `.zed/*` files for it. + Select { + project_dir: Option, + /// Non-interactive: pick this environment directly instead of + /// prompting. + #[arg(short = 'e', long)] + environment: Option, + }, +} + /// Resolve project_dir: prefer the subcommand's value, fall back to the top-level positional arg, /// then default to ".". This lets callers write either `fbuild build ` or `fbuild build`. pub fn resolve_project_dir( @@ -962,6 +996,7 @@ pub const KNOWN_SUBCOMMANDS: &[&str] = &[ "clang-tidy", "iwyu", "clangd-config", + "ide", "clang-query", "test-emu", "lib-select", diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs index 90676d31..0ca96327 100644 --- a/crates/fbuild-cli/src/cli/dispatch.rs +++ b/crates/fbuild-cli/src/cli/dispatch.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::{daemon_client, lib_select, mcp, output, update_check}; -use super::args::{BloatCmd, Cli, Commands, resolve_project_dir, rewrite_args}; +use super::args::{BloatCmd, Cli, Commands, IdeAction, resolve_project_dir, rewrite_args}; use super::bloat_lookup::run_bloat_lookup; use super::bringup::run_bringup; use super::build::run_build; @@ -21,6 +21,7 @@ use super::daemon_cmd::run_daemon; use super::deploy::{run_deploy, run_monitor, run_test_emu}; use super::device::run_device; use super::graph_cmd::run_bloat_graph; +use super::ide::{run_ide, run_ide_select}; use super::lnk::run_lnk; use super::monitor_parse::parse_monitor_flags; use super::pio::{pio_build, pio_deploy, pio_monitor}; @@ -459,6 +460,25 @@ pub async fn async_main() { let project_dir = resolve_project_dir(project_dir, &top_level_project_dir); run_clangd_config(project_dir, environment, verbose, editor, refresh).await } + Some(Commands::Ide { + project_dir, + environment, + no_launch, + action, + }) => match action { + Some(IdeAction::Select { + project_dir: select_project_dir, + environment: select_environment, + }) => { + let project_dir = + resolve_project_dir(select_project_dir.or(project_dir), &top_level_project_dir); + run_ide_select(project_dir, select_environment.or(environment)).await + } + None => { + let project_dir = resolve_project_dir(project_dir, &top_level_project_dir); + run_ide(project_dir, environment, no_launch).await + } + }, Some(Commands::TestEmu { project_dir, environment, diff --git a/crates/fbuild-cli/src/cli/ide.rs b/crates/fbuild-cli/src/cli/ide.rs new file mode 100644 index 00000000..045dfd7c --- /dev/null +++ b/crates/fbuild-cli/src/cli/ide.rs @@ -0,0 +1,625 @@ +//! `fbuild ide` / `fbuild ide select`: open (or configure) a project as an +//! IDE workspace on stock Zed (FastLED/fbuild#1076 Phase 1). +//! +//! This is a thin orchestrator over machinery that already exists: +//! +//! - Environment resolution / compile-DB freshness / `.clangd` + per-editor +//! config emission all come from the editor-neutral core in +//! `clangd_config` (`ensure_compile_db`, `emit_clangd_file`, +//! `emit_editor_config`) — FastLED/fbuild#1076 Phase 0. +//! - Declared-dep install goes through the daemon's existing +//! `POST /api/install-deps` handler, exactly like a fresh `fbuild build` +//! would trigger via the framework/library installer. +//! +//! What's new here: a persisted per-project "which environment is the IDE +//! configured for" choice (`.fbuild/ide_state.json`), an fbuild-owned +//! `.zed/tasks.json` (merge-don't-clobber: fbuild only touches tasks whose +//! label starts with `"fbuild: "`), and launching the `zed` process. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::daemon_client::{self, DaemonClient, InstallDepsRequest}; +use crate::output; + +use super::build::normalize_path; +use super::clangd_config::{Editor, emit_clangd_file, emit_editor_config, ensure_compile_db}; + +/// Label prefix that marks a Zed task as fbuild-owned. Merge logic replaces +/// every task with this prefix and leaves everything else untouched. +const FBUILD_TASK_PREFIX: &str = "fbuild: "; + +// --------------------------------------------------------------------- +// Persisted IDE state +// --------------------------------------------------------------------- + +/// Persisted per-project IDE state: `/.fbuild/ide_state.json`. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct IdeState { + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option, +} + +fn ide_state_path(project_path: &Path) -> PathBuf { + project_path.join(".fbuild").join("ide_state.json") +} + +/// Read the persisted environment. Tolerates an absent file, an empty file, +/// or malformed JSON — all of those degrade to `None` rather than erroring, +/// since a missing/corrupt state file just means "fall through to the next +/// resolution step", never a hard failure. +fn read_persisted_env(project_path: &Path) -> Option { + let content = std::fs::read_to_string(ide_state_path(project_path)).ok()?; + let state: IdeState = serde_json::from_str(&content).ok()?; + state.environment +} + +/// Persist the chosen environment, creating `.fbuild/` if needed. +fn write_persisted_env(project_path: &Path, environment: &str) -> fbuild_core::Result<()> { + let dir = project_path.join(".fbuild"); + std::fs::create_dir_all(&dir).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to create {}: {}", dir.display(), e)) + })?; + let state = IdeState { + environment: Some(environment.to_string()), + }; + let mut json = serde_json::to_string_pretty(&state).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to serialize ide state: {}", e)) + })?; + json.push('\n'); + let path = ide_state_path(project_path); + std::fs::write(&path, json).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to write {}: {}", path.display(), e)) + })?; + Ok(()) +} + +/// Resolve the environment to configure the IDE for: explicit `-e` wins, +/// then the persisted choice, then `platformio.ini`'s default environment. +fn resolve_ide_env(project_path: &Path, explicit: Option) -> fbuild_core::Result { + if let Some(env) = explicit { + return Ok(env); + } + if let Some(env) = read_persisted_env(project_path) { + return Ok(env); + } + let ini_path = project_path.join("platformio.ini"); + if !ini_path.exists() { + return Err(fbuild_core::FbuildError::ConfigError(format!( + "no platformio.ini found at {}", + ini_path.display() + ))); + } + let config = fbuild_config::PlatformIOConfig::from_path(&ini_path)?; + config + .get_default_environment() + .map(|s| s.to_string()) + .ok_or_else(|| { + fbuild_core::FbuildError::ConfigError( + "no environments defined in platformio.ini".into(), + ) + }) +} + +// --------------------------------------------------------------------- +// .zed/tasks.json — merge-don't-clobber +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ZedTask { + label: String, + command: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + 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 { + label: format!("{FBUILD_TASK_PREFIX}{label}"), + command: "fbuild".to_string(), + args: args.into_iter().map(str::to_string).collect(), + }; + vec![ + task("Build", vec!["build", "-e", env_name]), + task("Build (clean)", vec!["build", "-e", env_name, "--clean"]), + task("Deploy", vec!["deploy", "-e", env_name]), + task( + "Deploy + Monitor", + vec!["deploy", "-e", env_name, "--monitor"], + ), + task("Monitor", vec!["monitor", "-e", env_name]), + task("Reset", vec!["reset", "-e", env_name]), + task("Select environment", vec!["ide", "select"]), + ] +} + +/// Merge fbuild's tasks into `.zed/tasks.json`: any existing task whose +/// label starts with `"fbuild: "` is replaced (by position among the +/// fbuild-owned tasks); every other (user) task is preserved verbatim, in +/// its original position. +fn merge_tasks(existing: &[ZedTask], fbuild_tasks: &[ZedTask]) -> Vec { + let mut merged: Vec = existing + .iter() + .filter(|t| !t.label.starts_with(FBUILD_TASK_PREFIX)) + .cloned() + .collect(); + merged.extend(fbuild_tasks.iter().cloned()); + merged +} + +fn read_tasks_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/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 { + 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 mut json = serde_json::to_string_pretty(&merged).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to serialize tasks.json: {}", e)) + })?; + json.push('\n'); + std::fs::write(&tasks_path, json).map_err(|e| { + fbuild_core::FbuildError::Other(format!("failed to write {}: {}", tasks_path.display(), e)) + })?; + Ok(tasks_path) +} + +// --------------------------------------------------------------------- +// zed executable discovery + launch +// --------------------------------------------------------------------- + +/// Known install-location candidates for the `zed` executable, beyond +/// PATH, in probe order. Pure (no filesystem access) so it's directly +/// testable; callers are responsible for checking `.exists()`. +fn known_zed_install_candidates() -> Vec { + let mut candidates = Vec::new(); + if cfg!(windows) { + if let Some(local_appdata) = std::env::var_os("LOCALAPPDATA") { + let base = PathBuf::from(local_appdata); + candidates.push(base.join("Programs").join("Zed").join("zed.exe")); + candidates.push(base.join("Zed").join("zed.exe")); + } + } else if cfg!(target_os = "macos") { + candidates.push(PathBuf::from("/Applications/Zed.app/Contents/MacOS/cli")); + candidates.push(PathBuf::from("/usr/local/bin/zed")); + } else { + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(".local").join("bin").join("zed")); + } + candidates.push(PathBuf::from("/usr/bin/zed")); + } + candidates +} + +fn zed_exe_name() -> &'static str { + if cfg!(windows) { "zed.exe" } else { "zed" } +} + +/// Find `zed` on PATH first, then fall back to known install locations. +fn find_zed_executable() -> Option { + if let Some(path) = std::env::var_os("PATH") { + let exe_name = zed_exe_name(); + for dir in std::env::split_paths(&path) { + let candidate = dir.join(exe_name); + if candidate.is_file() { + return Some(candidate); + } + } + } + known_zed_install_candidates() + .into_iter() + .find(|p| p.is_file()) +} + +fn print_zed_install_guidance() { + output::result(""); + output::result("Zed was not found on PATH or in known install locations."); + output::result( + "IDE config was still generated — install Zed and open the project manually, or install it and re-run `fbuild ide`:", + ); + output::result(" Windows: winget install Zed.Zed"); + output::result(" macOS: brew install --cask zed"); + output::result(" Any OS: https://zed.dev/download"); +} + +/// Spawn `zed ` detached (fire-and-forget — the CLI does not +/// wait on the editor process). +fn launch_zed(zed_path: &Path, project_dir: &str) -> fbuild_core::Result<()> { + let mut cmd = std::process::Command::new(zed_path); + cmd.arg(project_dir); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + const DETACHED_PROCESS: u32 = 0x00000008; + cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); + } + cmd.spawn() + .map_err(|e| fbuild_core::FbuildError::Other(format!("failed to launch zed: {}", e)))?; + Ok(()) +} + +// --------------------------------------------------------------------- +// daemon-backed steps +// --------------------------------------------------------------------- + +/// Install declared platform/framework/library deps via the daemon, same +/// contract as `POST /api/install-deps` (`fbuild_daemon::models::InstallDepsRequest`). +async fn install_declared_deps(project_dir: &str, env_name: &str) -> fbuild_core::Result<()> { + daemon_client::ensure_daemon_running().await?; + let client = DaemonClient::new(); + let (caller_pid, caller_cwd) = daemon_client::caller_info(); + let req = InstallDepsRequest { + project_dir: project_dir.to_string(), + environment: Some(env_name.to_string()), + request_id: None, + caller_pid, + caller_cwd, + }; + output::progress("Installing declared dependencies..."); + let resp = client.install_deps(&req).await?; + if !resp.success { + return Err(fbuild_core::FbuildError::BuildFailed(format!( + "install-deps failed: {}", + resp.message + ))); + } + Ok(()) +} + +/// Regenerate compile DB + `.clangd` + `.zed/settings.json` + +/// `.zed/tasks.json` for `env_name`. Shared by `run_ide` and +/// `run_ide_select` so both paths refresh identically. +async fn regenerate_ide_config( + project_dir: &str, + project_path: &Path, + env_name: &str, + verbose: bool, +) -> fbuild_core::Result> { + let mut written = Vec::new(); + let db_path = ensure_compile_db(project_dir, project_path, env_name, verbose, true).await?; + written.push(db_path); + written.push(emit_clangd_file(project_path)?); + for (path, _written) in emit_editor_config(Editor::Zed, project_path)? { + written.push(path); + } + written.push(emit_zed_tasks(project_path, env_name)?); + Ok(written) +} + +// --------------------------------------------------------------------- +// public entry points +// --------------------------------------------------------------------- + +/// `fbuild ide [project_dir] [-e ] [--no-launch]` +pub async fn run_ide( + project_dir: String, + environment: Option, + no_launch: bool, +) -> fbuild_core::Result<()> { + let project_dir = normalize_path(&project_dir).await?; + let project_path = Path::new(&project_dir); + + let env_name = resolve_ide_env(project_path, environment)?; + output::progress(format!("Using environment: {}", env_name)); + write_persisted_env(project_path, &env_name)?; + + install_declared_deps(&project_dir, &env_name).await?; + + let written = regenerate_ide_config(&project_dir, project_path, &env_name, false).await?; + + output::result("\nGenerated IDE configuration:"); + for path in &written { + output::result(format!(" {}", path.display())); + } + + if no_launch { + output::result("\n--no-launch: skipping Zed launch."); + return Ok(()); + } + + match find_zed_executable() { + Some(zed_path) => { + output::progress(format!("Launching Zed ({})...", zed_path.display())); + launch_zed(&zed_path, &project_dir)?; + } + None => print_zed_install_guidance(), + } + + Ok(()) +} + +/// `fbuild ide select [project_dir] [-e ]` +/// +/// Interactively pick the environment (unless `-e` bypasses the prompt), +/// persist it, and regenerate the compile DB + `.clangd` + `.zed/*` config +/// for it. +pub async fn run_ide_select( + project_dir: String, + environment: Option, +) -> fbuild_core::Result<()> { + let project_dir = normalize_path(&project_dir).await?; + let project_path = Path::new(&project_dir); + + let ini_path = project_path.join("platformio.ini"); + if !ini_path.exists() { + return Err(fbuild_core::FbuildError::ConfigError(format!( + "no platformio.ini found at {}", + ini_path.display() + ))); + } + let config = fbuild_config::PlatformIOConfig::from_path(&ini_path)?; + let mut envs: Vec = config + .get_environments() + .into_iter() + .map(str::to_string) + .collect(); + envs.sort(); + if envs.is_empty() { + return Err(fbuild_core::FbuildError::ConfigError( + "no environments defined in platformio.ini".into(), + )); + } + + let chosen = match environment { + Some(env) => { + if !config.has_environment(&env) { + return Err(fbuild_core::FbuildError::ConfigError(format!( + "unknown environment '{}' — available: {}", + env, + envs.join(", ") + ))); + } + env + } + None => prompt_env_choice(&envs)?, + }; + + write_persisted_env(project_path, &chosen)?; + output::progress(format!("Selected environment: {}", chosen)); + + regenerate_ide_config(&project_dir, project_path, &chosen, false).await?; + output::result(format!( + "\nIDE configuration regenerated for environment '{}'.", + chosen + )); + Ok(()) +} + +/// Interactive numbered picker, modeled on `sync::prompt_multi_env`: reads +/// a single line from stdin, writes prompts to stderr so stdout stays +/// clean for pipelines. +fn prompt_env_choice(envs: &[String]) -> fbuild_core::Result { + use std::io::{BufRead, Write}; + eprintln!("Select the environment for this project's IDE config:"); + for (idx, env) in envs.iter().enumerate() { + eprintln!(" {}) {}", idx + 1, env); + } + eprint!("Enter a number [1-{}]: ", envs.len()); + let _ = std::io::stderr().flush(); + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .map_err(|e| fbuild_core::FbuildError::Other(format!("failed to read selection: {}", e)))?; + let choice: usize = line.trim().parse().map_err(|_| { + fbuild_core::FbuildError::Other(format!("'{}' is not a valid selection", line.trim())) + })?; + envs.get(choice.wrapping_sub(1)) + .cloned() + .ok_or_else(|| fbuild_core::FbuildError::Other(format!("'{}' is out of range", choice))) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---------- ide_state ---------- + + #[test] + fn ide_state_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + write_persisted_env(tmp.path(), "esp32dev").unwrap(); + assert_eq!(read_persisted_env(tmp.path()), Some("esp32dev".to_string())); + } + + #[test] + fn ide_state_absent_file_is_none() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(read_persisted_env(tmp.path()), None); + } + + #[test] + fn ide_state_malformed_json_is_none() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".fbuild")).unwrap(); + std::fs::write(ide_state_path(tmp.path()), "{ not json").unwrap(); + assert_eq!(read_persisted_env(tmp.path()), None); + } + + #[test] + fn ide_state_empty_file_is_none() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".fbuild")).unwrap(); + std::fs::write(ide_state_path(tmp.path()), "").unwrap(); + assert_eq!(read_persisted_env(tmp.path()), None); + } + + // ---------- env resolution precedence ---------- + + #[test] + fn resolve_ide_env_explicit_wins_over_persisted() { + let tmp = tempfile::tempdir().unwrap(); + write_persisted_env(tmp.path(), "persisted-env").unwrap(); + let resolved = resolve_ide_env(tmp.path(), Some("explicit-env".to_string())).unwrap(); + assert_eq!(resolved, "explicit-env"); + } + + #[test] + fn resolve_ide_env_persisted_wins_over_default() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:default_env]\nplatform = espressif32\n[env:other]\nplatform = atmelavr\n", + ) + .unwrap(); + write_persisted_env(tmp.path(), "other").unwrap(); + let resolved = resolve_ide_env(tmp.path(), None).unwrap(); + assert_eq!(resolved, "other"); + } + + #[test] + fn resolve_ide_env_falls_back_to_platformio_default() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("platformio.ini"), + "[env:only_env]\nplatform = espressif32\n", + ) + .unwrap(); + let resolved = resolve_ide_env(tmp.path(), None).unwrap(); + assert_eq!(resolved, "only_env"); + } + + #[test] + fn resolve_ide_env_errors_without_platformio_ini() { + let tmp = tempfile::tempdir().unwrap(); + assert!(resolve_ide_env(tmp.path(), None).is_err()); + } + + // ---------- tasks.json merge ---------- + + #[test] + fn build_fbuild_tasks_pins_environment_in_args() { + let tasks = build_fbuild_tasks("esp32dev"); + assert_eq!(tasks.len(), 7); + for label in [ + "fbuild: Build", + "fbuild: Build (clean)", + "fbuild: Deploy", + "fbuild: Deploy + Monitor", + "fbuild: Monitor", + "fbuild: Reset", + "fbuild: Select environment", + ] { + assert!( + tasks.iter().any(|t| t.label == label), + "missing task {label}" + ); + } + let build = tasks.iter().find(|t| t.label == "fbuild: Build").unwrap(); + assert_eq!(build.args, vec!["build", "-e", "esp32dev"]); + let select = tasks + .iter() + .find(|t| t.label == "fbuild: Select environment") + .unwrap(); + assert_eq!(select.args, vec!["ide", "select"]); + } + + #[test] + fn merge_tasks_preserves_user_tasks_and_replaces_fbuild_owned() { + let existing = vec![ + ZedTask { + label: "My custom task".to_string(), + command: "echo".to_string(), + args: vec!["hi".to_string()], + }, + ZedTask { + label: "fbuild: Build".to_string(), + command: "fbuild".to_string(), + args: vec!["build".to_string(), "-e".to_string(), "stale".to_string()], + }, + ]; + let fresh = build_fbuild_tasks("esp32dev"); + let merged = merge_tasks(&existing, &fresh); + + assert!(merged.iter().any(|t| t.label == "My custom task")); + let build = merged.iter().find(|t| t.label == "fbuild: Build").unwrap(); + assert_eq!(build.args, vec!["build", "-e", "esp32dev"]); + // Stale fbuild-owned task replaced, not duplicated. + assert_eq!( + merged.iter().filter(|t| t.label == "fbuild: Build").count(), + 1 + ); + } + + #[test] + fn merge_tasks_is_idempotent() { + let fresh = build_fbuild_tasks("esp32dev"); + let once = merge_tasks(&[], &fresh); + let twice = merge_tasks(&once, &fresh); + assert_eq!(once, twice); + } + + #[test] + fn emit_zed_tasks_preserves_user_task_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("tasks.json"), + r#"[{"label": "My custom task", "command": "echo", "args": ["hi"]}]"#, + ) + .unwrap(); + + let path = emit_zed_tasks(tmp.path(), "esp32dev").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")); + assert!(tasks.iter().any(|t| t.label == "fbuild: Build")); + } + + // ---------- zed executable discovery (pure candidate list only) ---------- + + #[test] + fn known_zed_install_candidates_nonempty_when_env_vars_present() { + // Just assert the function runs and returns platform-appropriate + // shapes without touching the filesystem — actual existence checks + // happen in `find_zed_executable`, which we deliberately don't + // test here (must not launch/require zed in CI). + let candidates = known_zed_install_candidates(); + if cfg!(windows) { + assert!( + candidates + .iter() + .all(|p| p.to_string_lossy().ends_with("zed.exe")) + ); + } else { + assert!( + candidates + .iter() + .all(|p| p.to_string_lossy().contains("zed")) + ); + } + } + + #[test] + fn zed_exe_name_matches_platform() { + let name = zed_exe_name(); + if cfg!(windows) { + assert_eq!(name, "zed.exe"); + } else { + assert_eq!(name, "zed"); + } + } +} diff --git a/crates/fbuild-cli/src/cli/mod.rs b/crates/fbuild-cli/src/cli/mod.rs index 8123134a..9bc019c8 100644 --- a/crates/fbuild-cli/src/cli/mod.rs +++ b/crates/fbuild-cli/src/cli/mod.rs @@ -23,6 +23,7 @@ pub mod deploy; pub mod device; pub mod dispatch; pub mod graph_cmd; +pub mod ide; pub mod lnk; pub mod monitor_parse; pub mod pio; diff --git a/crates/fbuild-cli/src/cli/tests.rs b/crates/fbuild-cli/src/cli/tests.rs index e36bfc33..0039da9c 100644 --- a/crates/fbuild-cli/src/cli/tests.rs +++ b/crates/fbuild-cli/src/cli/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for CLI argument normalization and `fbuild ci` parsing. -use super::args::{Cli, Commands, DaemonAction}; +use super::args::{Cli, Commands, DaemonAction, IdeAction}; use super::compile_many::{build_ci_pio_env, normalize_ci_sketch_entry, normalize_ci_sketches}; use clap::Parser; @@ -9,6 +9,105 @@ fn deploy_admin_and_no_admin_conflict() { assert!(Cli::try_parse_from(["fbuild", "deploy", "--admin", "--no-admin"]).is_err()); } +// ---------- `fbuild ide` / `fbuild ide select` CLI shape ---------- + +#[test] +fn ide_with_no_args_has_no_project_dir_and_no_action() { + let cli = Cli::try_parse_from(["fbuild", "ide"]).expect("parse"); + match cli.command { + Some(Commands::Ide { + project_dir, + action, + no_launch, + .. + }) => { + assert_eq!(project_dir, None); + assert!(action.is_none()); + assert!(!no_launch); + } + _ => panic!("expected Commands::Ide"), + } +} + +#[test] +fn ide_with_project_dir_is_not_mistaken_for_select() { + let cli = Cli::try_parse_from(["fbuild", "ide", "myproject"]).expect("parse"); + match cli.command { + Some(Commands::Ide { + project_dir, + action, + .. + }) => { + assert_eq!(project_dir, Some("myproject".to_string())); + assert!(action.is_none()); + } + _ => panic!("expected Commands::Ide"), + } +} + +#[test] +fn ide_flags_parse() { + let cli = Cli::try_parse_from([ + "fbuild", + "ide", + "myproject", + "-e", + "esp32dev", + "--no-launch", + ]) + .expect("parse"); + match cli.command { + Some(Commands::Ide { + project_dir, + environment, + no_launch, + action, + }) => { + assert_eq!(project_dir, Some("myproject".to_string())); + assert_eq!(environment, Some("esp32dev".to_string())); + assert!(no_launch); + assert!(action.is_none()); + } + _ => panic!("expected Commands::Ide"), + } +} + +#[test] +fn ide_select_with_no_project_dir_parses_as_select_action() { + let cli = Cli::try_parse_from(["fbuild", "ide", "select"]).expect("parse"); + match cli.command { + Some(Commands::Ide { action, .. }) => { + assert!(matches!( + action, + Some(IdeAction::Select { + project_dir: None, + environment: None + }) + )); + } + _ => panic!("expected Commands::Ide"), + } +} + +#[test] +fn ide_select_with_project_dir_and_environment_parses() { + let cli = + Cli::try_parse_from(["fbuild", "ide", "select", "myproject", "-e", "uno"]).expect("parse"); + match cli.command { + Some(Commands::Ide { action, .. }) => match action { + Some(IdeAction::Select { + project_dir, + environment, + }) => { + assert_eq!(project_dir, Some("myproject".to_string())); + assert_eq!(environment, Some("uno".to_string())); + } + other => panic!("expected IdeAction::Select, got {other:?}"), + }, + _ => panic!("expected Commands::Ide"), + } +} + #[test] fn hidden_usb_recovery_helper_arguments_parse() { let cli = Cli::try_parse_from([ diff --git a/crates/fbuild-cli/src/daemon_client.rs b/crates/fbuild-cli/src/daemon_client.rs index 81b9d475..8dcdfb22 100644 --- a/crates/fbuild-cli/src/daemon_client.rs +++ b/crates/fbuild-cli/src/daemon_client.rs @@ -414,6 +414,18 @@ impl DaemonClient { .await } + /// Install declared platform/framework/library dependencies without + /// building (FastLED/fbuild#1076 Phase 1: `fbuild ide` runs this before + /// generating clangd/editor config so a fresh checkout has headers to + /// index). + pub async fn install_deps( + &self, + req: &InstallDepsRequest, + ) -> fbuild_core::Result { + self.post_operation("/api/install-deps", req, Some(LONG_OPERATION_TIMEOUT)) + .await + } + /// Get daemon info (PID, port, uptime, etc.). pub async fn daemon_info(&self) -> fbuild_core::Result { let resp = self diff --git a/crates/fbuild-cli/src/daemon_client/types.rs b/crates/fbuild-cli/src/daemon_client/types.rs index 2b2cbb41..ddb136d8 100644 --- a/crates/fbuild-cli/src/daemon_client/types.rs +++ b/crates/fbuild-cli/src/daemon_client/types.rs @@ -57,6 +57,21 @@ pub struct BuildRequest { pub bloat_analysis: bool, } +/// `POST /api/install-deps` request. Mirrors +/// `fbuild_daemon::models::InstallDepsRequest` field-for-field. +#[derive(Clone, Debug, Serialize)] +pub struct InstallDepsRequest { + pub project_dir: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caller_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caller_cwd: Option, +} + #[derive(Clone, Debug, Serialize)] pub struct DeployRequest { pub project_dir: String, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9dc7f618..66e30c93 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -148,6 +148,8 @@ known limitations. | `fbuild bloat lookup --symbol ` | Inspect one symbol's size and references. | | `fbuild lib-select` | Debug LDF-style library selection. | | `fbuild clangd-config [--editor vscode\|zed] [--refresh]` | Emit `.clangd` (editor-neutral) plus per-editor project config (`.vscode/*` or `.zed/*`). `--editor` selects the emitter (default `vscode`); `--refresh` forces `compile_commands.json` regeneration even if it already exists. | +| `fbuild ide [project_dir] [-e ] [--no-launch]` | Open the project as an IDE workspace on stock Zed. See [`fbuild ide`](#fbuild-ide) below. | +| `fbuild ide select [project_dir] [-e ]` | Interactively (or with `-e`) choose the environment used for the IDE config, persist it, and regenerate. | | `fbuild clang-tidy` | Run clang-tidy against project sources. | | `fbuild iwyu` | Run include-what-you-use analysis. | | `fbuild clang-query` | Run a clang-query matcher. | @@ -156,6 +158,50 @@ known limitations. | `fbuild lnk add ` | Create a `.lnk` manifest for a remote blob. | | `fbuild mcp` | Start the MCP server for AI assistant integration. | +### `fbuild ide` + +Open (or configure) a project as an IDE workspace on stock Zed +(FastLED/fbuild#1076 Phase 1). This installs the project's declared +dependencies, refreshes `compile_commands.json`, writes the same +editor-neutral `.clangd` as `fbuild clangd-config --editor zed`, merges an +fbuild-owned `.zed/tasks.json`, and — unless `--no-launch` is given — +launches Zed. + +```bash +fbuild ide # current dir, resolved env, launches Zed +fbuild ide tests/platform/uno -e uno +fbuild ide --no-launch # generate/refresh config only +fbuild ide select # interactive environment picker +fbuild ide select -e esp32dev # non-interactive: pick esp32dev directly +``` + +Environment resolution, in order: an explicit `-e`, then the environment +persisted by a previous `fbuild ide` / `fbuild ide select` run +(`/.fbuild/ide_state.json`), then `platformio.ini`'s default +environment. + +Generated/updated files: + +- `compile_commands.json` — regenerated on every `fbuild ide` invocation + (equivalent to `fbuild build -t compiledb`). Machine-specific (absolute + paths) — **recommend adding it to `.gitignore`**. +- `.clangd` — editor-neutral, safe to commit. +- `.zed/settings.json` — merge-don't-clobber (maps `.ino` to C++, sets + `lsp.clangd.binary.arguments`); safe to commit. +- `.zed/tasks.json` — merge-don't-clobber: fbuild only replaces tasks whose + label starts with `"fbuild: "` (Build, Build (clean), Deploy, Deploy + + Monitor, Monitor, Reset, Select environment); any other task you've added + is left untouched. Safe to commit. +- `.fbuild/ide_state.json` — the persisted environment choice. Local + developer state; recommend `.gitignore`. + +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 +successfully — it just prints install guidance (`winget install Zed.Zed`, +`brew install --cask zed`, or ) instead of +launching the editor. Config generation is the product; launching Zed is a +convenience on top of it. + ## Batch And CI Commands ### `fbuild compile-many`