diff --git a/crates/fbuild-build-arm/src/stm32/orchestrator/arduino_mbed.rs b/crates/fbuild-build-arm/src/stm32/orchestrator/arduino_mbed.rs
index d452cafe9..79acf6800 100644
--- a/crates/fbuild-build-arm/src/stm32/orchestrator/arduino_mbed.rs
+++ b/crates/fbuild-build-arm/src/stm32/orchestrator/arduino_mbed.rs
@@ -56,14 +56,17 @@ pub(super) async fn build_arduino_mbed_stm32(
let variant_dir = framework.get_variant_dir(&ctx.board.variant);
let scanner = SourceScanner::new(&ctx.src_dir, &ctx.src_build_dir);
- let sources = SourceCollection {
- sketch_sources: scanner.scan_sketch_sources_filtered_with_include_roots(
+ let (sketch_sources, ino_preludes) = scanner
+ .scan_sketch_sources_filtered_with_include_roots_and_preludes(
ctx.source_filter.as_deref(),
&[core_dir.as_path(), variant_dir.as_path()],
- )?,
+ )?;
+ let sources = SourceCollection {
+ sketch_sources,
core_sources: framework.get_core_sources(),
variant_sources: framework.get_variant_sources(&ctx.board.variant),
headers: Vec::new(),
+ ino_preludes,
};
tracing::info!(
diff --git a/crates/fbuild-build-engine/src/compile_database/clang.rs b/crates/fbuild-build-engine/src/compile_database/clang.rs
index 26edaee5d..4b76615b5 100644
--- a/crates/fbuild-build-engine/src/compile_database/clang.rs
+++ b/crates/fbuild-build-engine/src/compile_database/clang.rs
@@ -74,22 +74,115 @@ pub fn translate_flags_for_clang(args: &[String], arch: TargetArchitecture) -> V
result
}
+/// Deterministic, deduplicated `-isystem
` args for the GCC toolchain's
+/// builtin include directories (`stdbool.h`, `stddef.h`, `stdarg.h`, etc. —
+/// implicit GCC search paths that never appear in `compile_commands.json`
+/// because GCC adds them automatically).
+///
+/// clangd has no such implicit search path, so without these baked in as
+/// `-isystem` it can't find those headers. Previously the only fix was a
+/// `--query-driver` clangd argument asking clangd to *run* the real compiler
+/// and ask it — but `translate_for_clang` (this same function) already
+/// rewrites `arguments[0]` to bare `clang`/`clang++`, which made
+/// `--query-driver` resolve to nothing useful (FastLED/fbuild#1076 Phase 0).
+/// Baking the dirs in directly is robust on every platform and doesn't
+/// require clangd to shell out to anything.
+///
+/// Empty (no-op) when no toolchain is cached yet — never fails a build.
+fn builtin_isystem_args() -> Vec {
+ isystem_args_from_dirs(fbuild_packages::toolchain::clang::find_gcc_builtin_include_dirs())
+}
+
+/// Sort + dedup a list of include dirs and flatten it into `-isystem `
+/// pairs. Pulled out of [`builtin_isystem_args`] so the sort/dedup/flatten
+/// logic is unit-testable without depending on (or mutating) the real
+/// toolchain cache directory.
+pub(super) fn isystem_args_from_dirs(mut dirs: Vec) -> Vec {
+ dirs.sort();
+ dirs.dedup();
+
+ let mut args = Vec::with_capacity(dirs.len() * 2);
+ for dir in dirs {
+ args.push("-isystem".to_string());
+ args.push(dir.to_string_lossy().to_string());
+ }
+ args
+}
+
impl CompileDatabase {
- /// Create a new compile database with GCC flags translated to clang equivalents.
+ /// Create a new compile database with GCC flags translated to clang
+ /// equivalents, with the toolchain's GCC builtin include dirs baked in
+ /// as `-isystem` (see [`builtin_isystem_args`]).
pub fn translate_for_clang(&self, arch: TargetArchitecture) -> CompileDatabase {
+ let builtin_includes = builtin_isystem_args();
let entries = self
.entries
.iter()
- .map(|entry| CompileEntry {
- arguments: translate_flags_for_clang(&entry.arguments, arch),
- directory: entry.directory.clone(),
- file: entry.file.clone(),
- output: entry.output.clone(),
+ .map(|entry| {
+ let mut arguments = translate_flags_for_clang(&entry.arguments, arch);
+ arguments.extend(builtin_includes.iter().cloned());
+ CompileEntry {
+ arguments,
+ directory: entry.directory.clone(),
+ file: entry.file.clone(),
+ output: entry.output.clone(),
+ }
})
.collect();
CompileDatabase { entries }
}
+ /// Swap the generated `.ino.cpp` compile entry for one raw-`.ino`
+ /// entry per tab (FastLED/fbuild#1076 Phase 0 — IDE-flavored compile DB;
+ /// direction update: "use the converter's `.ino.cpp` output for clangd
+ /// IntelliSense").
+ ///
+ /// clangd analyzes the live text of whatever file is open in the editor.
+ /// An entry naming the generated `.ino.cpp` means unsaved edits to the
+ /// sketch are invisible and diagnostics point at a file nobody is
+ /// looking at. Each raw `.ino` instead gets the generated entry's
+ /// (already clang-translated) flags plus `-x c++ -include `,
+ /// with the `file` field and the `-c ` / `file` argument swapped to
+ /// the raw `.ino` path. The generated `.ino.cpp` entry is removed —
+ /// keeping both would double-index the same code under two translation
+ /// units and confuse go-to-definition.
+ ///
+ /// No-op (including: does not remove the generated entry) when
+ /// `ino_preludes` is empty — i.e. no `.ino` tabs were preprocessed for
+ /// this build (no `.ino` files, or `main.cpp` skipped preprocessing).
+ pub fn swap_ino_entries_for_raw(&self, ino_preludes: &[(PathBuf, PathBuf)]) -> CompileDatabase {
+ if ino_preludes.is_empty() {
+ return CompileDatabase {
+ entries: self.entries.clone(),
+ };
+ }
+
+ let mut template: Option<&CompileEntry> = None;
+ let mut entries: Vec =
+ Vec::with_capacity(self.entries.len() + ino_preludes.len());
+ for entry in &self.entries {
+ if entry.file.ends_with(".ino.cpp") {
+ template = Some(entry);
+ continue;
+ }
+ entries.push(entry.clone());
+ }
+
+ let Some(template) = template else {
+ // No generated .ino.cpp entry present — leave the database as-is
+ // rather than silently dropping something unexpected.
+ return CompileDatabase {
+ entries: self.entries.clone(),
+ };
+ };
+
+ for (raw_ino, prelude) in ino_preludes {
+ entries.push(raw_ino_entry_from_template(template, raw_ino, prelude));
+ }
+
+ CompileDatabase { entries }
+ }
+
/// Prepare compile database for IWYU (include-what-you-use) analysis.
///
/// Transforms the existing (already clang-translated) compile database so that
@@ -167,3 +260,41 @@ impl CompileDatabase {
CompileDatabase { entries }
}
}
+
+/// Build one raw-`.ino` compile entry from the generated `.ino.cpp` entry's
+/// (already clang-translated) flags: insert `-x c++ -include `
+/// right before `-c`, and swap every argument that names the generated file
+/// (the `-c ` argument) for the raw `.ino` path.
+fn raw_ino_entry_from_template(
+ template: &CompileEntry,
+ raw_ino: &Path,
+ prelude: &Path,
+) -> CompileEntry {
+ let raw_ino_str = raw_ino.to_string_lossy().to_string();
+ let prelude_str = prelude.to_string_lossy().to_string();
+
+ let mut arguments = Vec::with_capacity(template.arguments.len() + 4);
+ let mut inserted_flavor = false;
+ for arg in &template.arguments {
+ if !inserted_flavor && arg == "-c" {
+ arguments.push("-x".to_string());
+ arguments.push("c++".to_string());
+ arguments.push("-include".to_string());
+ arguments.push(prelude_str.clone());
+ inserted_flavor = true;
+ }
+
+ if *arg == template.file {
+ arguments.push(raw_ino_str.clone());
+ } else {
+ arguments.push(arg.clone());
+ }
+ }
+
+ CompileEntry {
+ arguments,
+ directory: template.directory.clone(),
+ file: raw_ino_str,
+ output: template.output.clone(),
+ }
+}
diff --git a/crates/fbuild-build-engine/src/compile_database/tests/clang.rs b/crates/fbuild-build-engine/src/compile_database/tests/clang.rs
index 06d0c7d24..724c6b1ed 100644
--- a/crates/fbuild-build-engine/src/compile_database/tests/clang.rs
+++ b/crates/fbuild-build-engine/src/compile_database/tests/clang.rs
@@ -2,7 +2,7 @@
use std::path::{Path, PathBuf};
-use super::super::clang::should_remove_flag;
+use super::super::clang::{isystem_args_from_dirs, should_remove_flag};
use crate::compile_database::{
CompileDatabase, CompileEntry, TargetArchitecture, translate_flags_for_clang,
};
@@ -225,6 +225,160 @@ fn test_database_translate_for_clang() {
);
}
+// =========================================================================
+// FastLED/fbuild#1076 Phase 0: builtin include-dir baking + raw-.ino swap
+// =========================================================================
+
+#[test]
+fn test_isystem_args_from_dirs_sorted_and_deduped() {
+ let dirs = vec![
+ PathBuf::from("/tc/lib/gcc/xtensa/14/include"),
+ PathBuf::from("/tc/lib/gcc/avr/14/include"),
+ PathBuf::from("/tc/lib/gcc/xtensa/14/include"), // duplicate
+ ];
+ let args = isystem_args_from_dirs(dirs);
+ assert_eq!(
+ args,
+ vec![
+ "-isystem".to_string(),
+ "/tc/lib/gcc/avr/14/include".to_string(),
+ "-isystem".to_string(),
+ "/tc/lib/gcc/xtensa/14/include".to_string(),
+ ]
+ );
+}
+
+#[test]
+fn test_isystem_args_from_dirs_empty_is_no_op() {
+ assert!(isystem_args_from_dirs(Vec::new()).is_empty());
+}
+
+fn ino_cpp_template_entry() -> CompileEntry {
+ CompileEntry {
+ arguments: vec![
+ "clang++".to_string(),
+ "--target=xtensa-esp-elf".to_string(),
+ "-Isrc".to_string(),
+ "-c".to_string(),
+ "/project/.fbuild/build/env/src/sketch.ino.cpp".to_string(),
+ "-o".to_string(),
+ "/project/.fbuild/build/env/src/sketch.ino.cpp.o".to_string(),
+ ],
+ directory: "/project".to_string(),
+ file: "/project/.fbuild/build/env/src/sketch.ino.cpp".to_string(),
+ output: Some("/project/.fbuild/build/env/src/sketch.ino.cpp.o".to_string()),
+ }
+}
+
+#[test]
+fn test_swap_ino_entries_for_raw_replaces_generated_entry() {
+ let mut db = CompileDatabase::new();
+ db.add_entry(ino_cpp_template_entry());
+
+ let ino_preludes = vec![(
+ PathBuf::from("/project/src/sketch.ino"),
+ PathBuf::from("/project/.fbuild/build/env/src/sketch.ino.prelude.h"),
+ )];
+ let swapped = db.swap_ino_entries_for_raw(&ino_preludes);
+
+ // Generated .ino.cpp entry removed.
+ assert!(!swapped.entries.iter().any(|e| e.file.ends_with(".ino.cpp")));
+
+ // Raw .ino entry present with -x c++ -include and the raw
+ // path swapped in for both `file` and the `-c ` argument.
+ assert_eq!(swapped.entries.len(), 1);
+ let entry = &swapped.entries[0];
+ assert_eq!(entry.file, "/project/src/sketch.ino");
+ assert!(entry.arguments.contains(&"-x".to_string()));
+ assert!(entry.arguments.contains(&"c++".to_string()));
+ let include_idx = entry
+ .arguments
+ .iter()
+ .position(|a| a == "-include")
+ .unwrap();
+ assert_eq!(
+ entry.arguments[include_idx + 1],
+ "/project/.fbuild/build/env/src/sketch.ino.prelude.h"
+ );
+ assert!(
+ entry
+ .arguments
+ .contains(&"/project/src/sketch.ino".to_string())
+ );
+ assert!(!entry.arguments.iter().any(|a| a.ends_with(".ino.cpp")));
+ // Flags from the template are preserved.
+ assert!(entry.arguments.contains(&"-Isrc".to_string()));
+ assert!(
+ entry
+ .arguments
+ .contains(&"--target=xtensa-esp-elf".to_string())
+ );
+}
+
+#[test]
+fn test_swap_ino_entries_for_raw_multi_tab() {
+ let mut db = CompileDatabase::new();
+ db.add_entry(ino_cpp_template_entry());
+
+ let ino_preludes = vec![
+ (
+ PathBuf::from("/project/src/main.ino"),
+ PathBuf::from("/project/.fbuild/build/env/src/main.ino.prelude.h"),
+ ),
+ (
+ PathBuf::from("/project/src/a_tab.ino"),
+ PathBuf::from("/project/.fbuild/build/env/src/a_tab.ino.prelude.h"),
+ ),
+ ];
+ let swapped = db.swap_ino_entries_for_raw(&ino_preludes);
+
+ assert_eq!(swapped.entries.len(), 2);
+ assert!(
+ swapped
+ .entries
+ .iter()
+ .any(|e| e.file == "/project/src/main.ino")
+ );
+ assert!(
+ swapped
+ .entries
+ .iter()
+ .any(|e| e.file == "/project/src/a_tab.ino")
+ );
+ assert!(!swapped.entries.iter().any(|e| e.file.ends_with(".ino.cpp")));
+}
+
+#[test]
+fn test_swap_ino_entries_for_raw_empty_preludes_is_no_op() {
+ let mut db = CompileDatabase::new();
+ db.add_entry(ino_cpp_template_entry());
+
+ let swapped = db.swap_ino_entries_for_raw(&[]);
+ // The generated entry survives untouched — this is the main.cpp-present
+ // / no-.ino-tabs case, where there is nothing to swap.
+ assert_eq!(swapped.entries.len(), 1);
+ assert!(swapped.entries[0].file.ends_with(".ino.cpp"));
+}
+
+#[test]
+fn test_swap_ino_entries_for_raw_no_generated_entry_leaves_db_untouched() {
+ let mut db = CompileDatabase::new();
+ db.add_entry(CompileEntry {
+ arguments: vec!["clang".to_string(), "-c".to_string(), "main.c".to_string()],
+ directory: "/project".to_string(),
+ file: "main.c".to_string(),
+ output: None,
+ });
+
+ let ino_preludes = vec![(
+ PathBuf::from("/project/src/sketch.ino"),
+ PathBuf::from("/project/.fbuild/build/env/src/sketch.ino.prelude.h"),
+ )];
+ let swapped = db.swap_ino_entries_for_raw(&ino_preludes);
+ assert_eq!(swapped.entries.len(), 1);
+ assert_eq!(swapped.entries[0].file, "main.c");
+}
+
#[test]
fn test_translate_does_not_modify_original() {
let mut db = CompileDatabase::new();
diff --git a/crates/fbuild-build-engine/src/pipeline/compile.rs b/crates/fbuild-build-engine/src/pipeline/compile.rs
index 134eabba2..2a13d2ada 100644
--- a/crates/fbuild-build-engine/src/pipeline/compile.rs
+++ b/crates/fbuild-build-engine/src/pipeline/compile.rs
@@ -116,6 +116,13 @@ pub async fn compile_local_libraries(
}
/// Generate `compile_commands.json` from core/variant and sketch sources.
+///
+/// IDE-flavored: when `ino_preludes` is non-empty (i.e. the sketch had
+/// `.ino` tabs preprocessed into a generated `.ino.cpp`), the
+/// generated file's entry is swapped for one raw-`.ino` entry per tab so
+/// clangd gives IntelliSense on the file the user actually edits
+/// (FastLED/fbuild#1076 Phase 0). See
+/// [`compile_database::CompileDatabase::swap_ino_entries_for_raw`].
#[allow(clippy::too_many_arguments)]
pub fn generate_compile_db(
gcc_path: &Path,
@@ -127,6 +134,7 @@ pub fn generate_compile_db(
all_src_flags: &LanguageExtraFlags,
core_sources: &[PathBuf],
sketch_sources: &[PathBuf],
+ ino_preludes: &[(PathBuf, PathBuf)],
core_build_dir: &Path,
src_build_dir: &Path,
build_dir: &Path,
@@ -157,6 +165,7 @@ pub fn generate_compile_db(
project_dir,
));
let compile_db = compile_db.translate_for_clang(arch);
+ let compile_db = compile_db.swap_ino_entries_for_raw(ino_preludes);
if compile_db.has_entries() {
Ok(Some(compile_db.write_and_copy(build_dir, project_dir)?))
} else {
diff --git a/crates/fbuild-build-engine/src/pipeline/sequential.rs b/crates/fbuild-build-engine/src/pipeline/sequential.rs
index 75e19cb8a..1da628b6a 100644
--- a/crates/fbuild-build-engine/src/pipeline/sequential.rs
+++ b/crates/fbuild-build-engine/src/pipeline/sequential.rs
@@ -70,6 +70,7 @@ pub async fn run_sequential_build_with_libs(
&src_overlay,
&core_and_variant,
&sources.sketch_sources,
+ &sources.ino_preludes,
&ctx.core_build_dir,
&ctx.src_build_dir,
&ctx.build_dir,
@@ -301,6 +302,7 @@ pub async fn run_sequential_build_with_libs(
&src_overlay,
&core_and_variant,
&sources.sketch_sources,
+ &sources.ino_preludes,
&ctx.core_build_dir,
&ctx.src_build_dir,
&ctx.build_dir,
diff --git a/crates/fbuild-build-engine/src/source_scanner.rs b/crates/fbuild-build-engine/src/source_scanner.rs
index 28d092152..a22d30bd6 100644
--- a/crates/fbuild-build-engine/src/source_scanner.rs
+++ b/crates/fbuild-build-engine/src/source_scanner.rs
@@ -45,6 +45,10 @@ fn normalize_glob_separators(pattern: &str) -> String {
pattern.replace('\\', "/")
}
+/// Raw-`.ino` path → prelude-header path pairs (FastLED/fbuild#1076 Phase 0).
+/// See [`SourceCollection::ino_preludes`].
+pub type InoPreludeMap = Vec<(PathBuf, PathBuf)>;
+
/// Collection of source files found by the scanner.
#[derive(Debug, Default)]
pub struct SourceCollection {
@@ -56,6 +60,17 @@ pub struct SourceCollection {
pub variant_sources: Vec,
/// All header files (.h, .hpp) for dependency tracking
pub headers: Vec,
+ /// Raw-`.ino` → prelude-header path mapping (FastLED/fbuild#1076 Phase 0).
+ ///
+ /// Populated only when `.ino` tabs were preprocessed (empty when the
+ /// sketch has no `.ino` files, or when `main.cpp` skips preprocessing).
+ /// Each prelude file holds the machine-written top half that the
+ /// generated `.ino.cpp` normally carries inline (the `Arduino.h`
+ /// include + extracted prototypes, plus — for non-primary tabs — the
+ /// full text of every preceding tab). IDE-flavored compile-DB generation
+ /// uses this to swap the generated `.ino.cpp` entry for one raw-`.ino`
+ /// entry per tab with `-x c++ -include `.
+ pub ino_preludes: InoPreludeMap,
}
impl SourceCollection {
@@ -139,8 +154,25 @@ impl SourceScanner {
filter_spec: Option<&str>,
include_roots: &[&Path],
) -> fbuild_core::Result> {
+ let (sources, _ino_preludes) = self
+ .scan_sketch_sources_filtered_with_include_roots_and_preludes(
+ filter_spec,
+ include_roots,
+ )?;
+ Ok(sources)
+ }
+
+ /// Same as [`Self::scan_sketch_sources_filtered_with_include_roots`] but
+ /// also returns the raw-`.ino` → prelude-header mapping (see
+ /// [`SourceCollection::ino_preludes`]) so IDE-flavored compile-DB
+ /// generation can swap in raw-`.ino` entries (FastLED/fbuild#1076 Phase 0).
+ pub fn scan_sketch_sources_filtered_with_include_roots_and_preludes(
+ &self,
+ filter_spec: Option<&str>,
+ include_roots: &[&Path],
+ ) -> fbuild_core::Result<(Vec, InoPreludeMap)> {
if !self.src_dir.exists() {
- return Ok(Vec::new());
+ return Ok((Vec::new(), Vec::new()));
}
let mut sources = Vec::new();
@@ -176,14 +208,16 @@ impl SourceScanner {
// If main.cpp exists, skip preprocessing to avoid duplicate symbols when
// the .ino content is already compiled via #include in main.cpp.
+ let mut ino_preludes = Vec::new();
if !ino_files.is_empty() && main_cpp_path.is_none() {
let ino_files = order_ino_files(&self.src_dir, ino_files);
- let preprocessed =
+ let (preprocessed, preludes) =
self.preprocess_ino_files(&ino_files, arduino_header_available(include_roots))?;
sources.insert(0, preprocessed);
+ ino_preludes = preludes;
}
- Ok(sources)
+ Ok((sources, ino_preludes))
}
/// Scan an Arduino core directory for source files.
@@ -257,8 +291,11 @@ impl SourceScanner {
filter_spec: Option<&str>,
) -> fbuild_core::Result {
let include_roots: Vec<&Path> = [core_dir, variant_dir].into_iter().flatten().collect();
- let sketch_sources =
- self.scan_sketch_sources_filtered_with_include_roots(filter_spec, &include_roots)?;
+ let (sketch_sources, ino_preludes) = self
+ .scan_sketch_sources_filtered_with_include_roots_and_preludes(
+ filter_spec,
+ &include_roots,
+ )?;
let core_sources = core_dir
.map(|d| self.scan_core_sources(d))
.unwrap_or_default();
@@ -279,63 +316,59 @@ impl SourceScanner {
core_sources,
variant_sources,
headers,
+ ino_preludes,
})
}
- /// Preprocess .ino files into a single .cpp file.
+ /// Preprocess .ino files into a single .cpp file, plus per-tab prelude
+ /// headers for IDE use (FastLED/fbuild#1076 Phase 0).
///
- /// 1. Concatenate .ino files (primary sketch first, then tabs alphabetically)
- /// 2. Add `#include ` at top when available
- /// 3. Extract function prototypes
- /// 4. Add prototypes before first function definition
- /// 5. Add `#line` directives for debugging
+ /// 1. Read + normalize every tab (primary sketch first, then tabs alphabetically)
+ /// 2. Extract function prototypes from the concatenated tab text
+ /// 3. Build the shared "prelude" text: `#include ` (when
+ /// available) + the auto-generated prototype block
+ /// 4. Emit `/.ino.cpp` = prelude + every tab's text, each
+ /// preceded by its own `#line 1 ""` directive (previously
+ /// only the first tab got one — secondary-tab compile errors reported
+ /// the wrong file/line)
+ /// 5. Emit prelude header(s) alongside it via [`Self::write_ino_preludes`]
+ ///
+ /// Returns the generated `.ino.cpp` path and the raw-`.ino` → prelude
+ /// path mapping.
fn preprocess_ino_files(
&self,
ino_files: &[PathBuf],
include_arduino_h: bool,
- ) -> fbuild_core::Result {
- let mut combined = String::new();
- let mut line_offsets: Vec<(usize, &Path)> = Vec::new();
- let mut current_line = 1;
-
- for ino in ino_files {
- let content = normalize_generated_source_line_endings(&std::fs::read_to_string(ino)?);
- line_offsets.push((current_line, ino.as_path()));
- current_line += content.lines().count();
- if !combined.is_empty() {
- combined.push('\n');
- }
- combined.push_str(&content);
- }
-
- let prototypes = extract_function_prototypes(&combined);
-
- // Build output
- let mut output = String::new();
-
- if include_arduino_h {
- output.push_str("#include \n");
- }
-
- // Function prototypes
- if !prototypes.is_empty() {
- output.push_str("// Auto-generated function prototypes\n");
- for proto in &prototypes {
- output.push_str(proto);
- output.push_str(";\n");
+ ) -> fbuild_core::Result<(PathBuf, InoPreludeMap)> {
+ let contents: Vec = ino_files
+ .iter()
+ .map(|ino| -> fbuild_core::Result {
+ Ok(normalize_generated_source_line_endings(
+ &std::fs::read_to_string(ino)?,
+ ))
+ })
+ .collect::>>()?;
+
+ // Prototype extraction needs to see every tab's code, so it operates
+ // on the plain concatenation (no #line noise).
+ let combined_for_prototypes = contents.join("\n");
+ let prototypes = extract_function_prototypes(&combined_for_prototypes);
+
+ let prelude = self.build_ino_prelude(include_arduino_h, &prototypes);
+
+ // Body: every tab's own text with a `#line` directive at each
+ // boundary, so diagnostics in any tab — not just the first — map
+ // back to the right file/line.
+ let mut body = String::new();
+ for (ino, content) in ino_files.iter().zip(contents.iter()) {
+ body.push_str(&format!("#line 1 \"{}\"\n", self.line_directive_path(ino)));
+ body.push_str(content);
+ if !content.ends_with('\n') {
+ body.push('\n');
}
- output.push('\n');
}
- // #line directive for first file
- if let Some((_, first_file)) = line_offsets.first() {
- output.push_str(&format!(
- "#line 1 \"{}\"\n",
- self.line_directive_path(first_file)
- ));
- }
-
- output.push_str(&combined);
+ let output = format!("{prelude}{body}");
// Write to build directory
std::fs::create_dir_all(&self.build_dir)?;
@@ -344,11 +377,77 @@ impl SourceScanner {
let stem = ino_files[0]
.file_stem()
.unwrap_or_default()
- .to_string_lossy();
+ .to_string_lossy()
+ .to_string();
let output_path = self.build_dir.join(format!("{}.ino.cpp", stem));
write_if_changed(&output_path, &output)?;
- Ok(output_path)
+ let ino_preludes = self.write_ino_preludes(ino_files, &contents, &prelude)?;
+
+ Ok((output_path, ino_preludes))
+ }
+
+ /// Build the shared prelude text: `#include ` (when
+ /// available) + the auto-generated prototype block. This is exactly the
+ /// machine-written top half the generated `.ino.cpp` carries inline.
+ fn build_ino_prelude(&self, include_arduino_h: bool, prototypes: &[String]) -> String {
+ let mut prelude = String::new();
+ if include_arduino_h {
+ prelude.push_str("#include \n");
+ }
+ if !prototypes.is_empty() {
+ prelude.push_str("// Auto-generated function prototypes\n");
+ for proto in prototypes {
+ prelude.push_str(proto);
+ prelude.push_str(";\n");
+ }
+ prelude.push('\n');
+ }
+ prelude
+ }
+
+ /// Emit prelude header(s) so clangd can give the raw `.ino` files
+ /// first-class IntelliSense (FastLED/fbuild#1076 Phase 0 — direction
+ /// update: "use the converter's `.ino.cpp` output for clangd
+ /// IntelliSense").
+ ///
+ /// - Single tab: `/.ino.prelude.h` = exactly the shared
+ /// prelude text, so `prelude + "#line 1 ..." + sketch text` is
+ /// byte-identical to the generated `.ino.cpp`.
+ /// - Multi-tab: one prelude per tab, `/.ino.prelude.h`
+ /// = shared prelude + the full text of every tab that precedes this tab
+ /// in build order, each preceded by its own `#line` directive — i.e.
+ /// exactly what that tab would see in the real concatenated build.
+ ///
+ /// Returns the raw-`.ino` → prelude-path mapping in tab order.
+ fn write_ino_preludes(
+ &self,
+ ino_files: &[PathBuf],
+ contents: &[String],
+ prelude: &str,
+ ) -> fbuild_core::Result {
+ let mut mapping = Vec::with_capacity(ino_files.len());
+
+ for (i, ino) in ino_files.iter().enumerate() {
+ let mut tab_prelude = prelude.to_string();
+ for (prior_ino, prior_content) in ino_files[..i].iter().zip(contents[..i].iter()) {
+ tab_prelude.push_str(&format!(
+ "#line 1 \"{}\"\n",
+ self.line_directive_path(prior_ino)
+ ));
+ tab_prelude.push_str(prior_content);
+ if !prior_content.ends_with('\n') {
+ tab_prelude.push('\n');
+ }
+ }
+
+ let tab_stem = ino.file_stem().unwrap_or_default().to_string_lossy();
+ let prelude_path = self.build_dir.join(format!("{}.ino.prelude.h", tab_stem));
+ write_if_changed(&prelude_path, &tab_prelude)?;
+ mapping.push((ino.clone(), prelude_path));
+ }
+
+ Ok(mapping)
}
fn line_directive_path(&self, path: &Path) -> String {
diff --git a/crates/fbuild-build-engine/src/source_scanner/tests.rs b/crates/fbuild-build-engine/src/source_scanner/tests.rs
index 7560a7ec0..e86006cae 100644
--- a/crates/fbuild-build-engine/src/source_scanner/tests.rs
+++ b/crates/fbuild-build-engine/src/source_scanner/tests.rs
@@ -585,3 +585,128 @@ fn test_scan_sketch_sources_filtered_includes_only_selected_files() {
);
assert!(!sources.iter().any(|p| p.ends_with("helper.cpp")));
}
+
+// =========================================================================
+// FastLED/fbuild#1076 Phase 0: per-tab #line directives + prelude headers
+// =========================================================================
+
+#[test]
+fn test_every_tab_gets_a_line_directive() {
+ // Previously only the first tab got a `#line` directive — secondary-tab
+ // compile errors reported the wrong file/line.
+ let (_tmp, src_dir, build_dir) = setup_project(&[
+ ("main.ino", "void setup() {}\nvoid loop() {}\n"),
+ ("a_tab.ino", "void aTab() {}\n"),
+ ]);
+ let scanner = SourceScanner::new(&src_dir, &build_dir);
+ let sources = scanner.scan_sketch_sources().unwrap();
+ let content = fs::read_to_string(&sources[0]).unwrap();
+
+ assert!(content.contains("#line 1 \"src/main.ino\""));
+ assert!(content.contains("#line 1 \"src/a_tab.ino\""));
+ // Both directives, not just one.
+ assert_eq!(content.matches("#line 1").count(), 2);
+}
+
+#[test]
+fn test_single_tab_prelude_exact_match_with_generated_ino_cpp() {
+ let (_tmp, src_dir, build_dir) = setup_project(&[(
+ "sketch.ino",
+ "void loop() { blink(3); }\nvoid blink(int n) {}\n",
+ )]);
+ let core_dir = _tmp.path().join("core");
+ fs::create_dir_all(&core_dir).unwrap();
+ fs::write(core_dir.join("Arduino.h"), "#pragma once\n").unwrap();
+
+ let scanner = SourceScanner::new(&src_dir, &build_dir);
+ let collection = scanner.scan_all(Some(&core_dir), None).unwrap();
+ assert_eq!(collection.sketch_sources.len(), 1);
+ assert_eq!(collection.ino_preludes.len(), 1);
+
+ let ino_cpp_content = fs::read_to_string(&collection.sketch_sources[0]).unwrap();
+ let (raw_ino, prelude_path) = &collection.ino_preludes[0];
+ assert!(raw_ino.ends_with("sketch.ino"));
+ assert!(prelude_path.ends_with("sketch.ino.prelude.h"));
+
+ let prelude_content = fs::read_to_string(prelude_path).unwrap();
+ assert!(prelude_content.contains("#include "));
+ // `loop()`/`setup()` are Arduino entry points called by the runtime, not
+ // user code, so `extract_function_prototypes` deliberately excludes them
+ // (`is_arduino_entry_point_signature`) — only `blink` needs a prototype.
+ assert!(!prelude_content.contains("void loop();"));
+ assert!(prelude_content.contains("void blink(int n);"));
+ // No `#line` directive belongs in the prelude itself — it precedes the
+ // `#line`-tagged body.
+ assert!(!prelude_content.contains("#line"));
+
+ // prelude + "#line 1 ..." + sketch text == the generated .ino.cpp,
+ // byte-for-byte (the worked example from the direction-update comment).
+ let expected = format!(
+ "{prelude_content}#line 1 \"src/sketch.ino\"\nvoid loop() {{ blink(3); }}\nvoid blink(int n) {{}}\n"
+ );
+ assert_eq!(ino_cpp_content, expected);
+}
+
+#[test]
+fn test_multi_tab_prelude_includes_preceding_tabs_with_line_directives() {
+ let (_tmp, src_dir, build_dir) = setup_project(&[
+ ("main.ino", "void setup() {}\nvoid loop() { blink(1); }\n"),
+ ("a_tab.ino", "void blink(int n) {}\n"),
+ ]);
+ let scanner = SourceScanner::new(&src_dir, &build_dir);
+ let (sources, ino_preludes) = scanner
+ .scan_sketch_sources_filtered_with_include_roots_and_preludes(None, &[])
+ .unwrap();
+ assert_eq!(sources.len(), 1);
+ assert_eq!(ino_preludes.len(), 2);
+
+ // Primary tab (main.ino) is first in build order and has no preceding
+ // tabs, so its prelude carries no sketch text.
+ let (main_ino, main_prelude_path) = &ino_preludes[0];
+ assert!(main_ino.ends_with("main.ino"));
+ let main_prelude = fs::read_to_string(main_prelude_path).unwrap();
+ assert!(main_prelude.contains("void blink(int n);")); // prototypes from the whole set
+ assert!(!main_prelude.contains("void blink(int n) {}")); // no tab bodies yet
+ assert!(!main_prelude.contains("#line"));
+
+ // a_tab.ino comes after main.ino in build order, so its prelude carries
+ // main.ino's full text behind its own #line directive.
+ let (a_tab_ino, a_tab_prelude_path) = &ino_preludes[1];
+ assert!(a_tab_ino.ends_with("a_tab.ino"));
+ let a_tab_prelude = fs::read_to_string(a_tab_prelude_path).unwrap();
+ assert!(a_tab_prelude.contains("#line 1 \"src/main.ino\""));
+ assert!(a_tab_prelude.contains("void setup() {}"));
+ assert!(a_tab_prelude.contains("void loop() { blink(1); }"));
+}
+
+#[test]
+fn test_ino_prelude_write_if_changed_does_not_rewrite_unchanged_output() {
+ let (_tmp, src_dir, build_dir) =
+ setup_project(&[("sketch.ino", "void setup() {}\nvoid loop() {}\n")]);
+ let scanner = SourceScanner::new(&src_dir, &build_dir);
+
+ let first = scanner.scan_sketch_sources().unwrap();
+ let prelude_path = build_dir.join("sketch.ino.prelude.h");
+ assert!(prelude_path.exists());
+ let first_mtime = fs::metadata(&prelude_path).unwrap().modified().unwrap();
+
+ std::thread::sleep(std::time::Duration::from_millis(20));
+
+ let second = scanner.scan_sketch_sources().unwrap();
+ assert_eq!(first, second);
+ let second_mtime = fs::metadata(&prelude_path).unwrap().modified().unwrap();
+ assert_eq!(first_mtime, second_mtime);
+}
+
+#[test]
+fn test_main_cpp_mode_emits_no_preludes() {
+ let (_tmp, src_dir, build_dir) = setup_project(&[
+ ("main.cpp", "#include \"sketch.ino\"\n"),
+ ("sketch.ino", "void setup() {}\nvoid loop() {}\n"),
+ ]);
+ let scanner = SourceScanner::new(&src_dir, &build_dir);
+
+ let collection = scanner.scan_all(None, None).unwrap();
+ assert!(collection.ino_preludes.is_empty());
+ assert!(!build_dir.join("sketch.ino.prelude.h").exists());
+}
diff --git a/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs b/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs
index 5e6e93164..10f6a3425 100644
--- a/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs
+++ b/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs
@@ -573,6 +573,7 @@ impl BuildOrchestrator for Esp32Orchestrator {
&src_overlay,
&all_core_sources,
&sources.sketch_sources,
+ &sources.ino_preludes,
core_build_dir,
src_build_dir,
build_dir,
@@ -783,6 +784,7 @@ impl BuildOrchestrator for Esp32Orchestrator {
&src_overlay,
&all_core_sources,
&sources.sketch_sources,
+ &sources.ino_preludes,
core_build_dir,
src_build_dir,
build_dir,
diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs
index 9fefe5d9b..6945bdf13 100644
--- a/crates/fbuild-cli/src/cli/args.rs
+++ b/crates/fbuild-cli/src/cli/args.rs
@@ -491,8 +491,9 @@ pub enum Commands {
#[arg(short, long)]
verbose: bool,
},
- /// Emit clangd / VS Code config (.clangd, .vscode/settings.json) for the
- /// default env so go-to-definition and include resolution work in the IDE
+ /// Emit clangd / editor config (.clangd, plus per-editor project config)
+ /// for the default env so go-to-definition and include resolution work
+ /// in the IDE
#[command(name = "clangd-config")]
ClangdConfig {
project_dir: Option,
@@ -500,6 +501,14 @@ pub enum Commands {
environment: Option,
#[arg(short, long)]
verbose: bool,
+ /// Editor to emit per-editor project config for
+ #[arg(long, value_parser = ["vscode", "zed"], default_value = "vscode")]
+ editor: String,
+ /// Regenerate compile_commands.json even if it already exists
+ /// (FastLED/fbuild#1076 Phase 0: DB regeneration is a first-class,
+ /// cheap operation)
+ #[arg(long)]
+ refresh: bool,
},
/// Build firmware and run it in an emulator for testing
TestEmu {
diff --git a/crates/fbuild-cli/src/cli/clangd_config.rs b/crates/fbuild-cli/src/cli/clangd_config.rs
deleted file mode 100644
index 352d99643..000000000
--- a/crates/fbuild-cli/src/cli/clangd_config.rs
+++ /dev/null
@@ -1,419 +0,0 @@
-//! `fbuild clangd-config`: emit an IDE-ready clangd configuration for a
-//! project's default (or chosen) PlatformIO environment so that "Go to
-//! Definition", header hover, and include resolution work in VS Code / clangd
-//! without any manual setup.
-//!
-//! The command sits on top of the existing `compile_database` machinery: it
-//! ensures `compile_commands.json` exists (via `build -t compiledb`), reads the
-//! real cross-compiler path out of it, and writes `.clangd`,
-//! `.vscode/settings.json`, and `.vscode/extensions.json` at the project root.
-//! It does not touch the build pipeline.
-
-use crate::output;
-
-use super::build::{normalize_path, run_build};
-
-/// Generate clangd / VS Code configuration for the project's default env.
-pub async fn run_clangd_config(
- project_dir: String,
- environment: Option,
- verbose: bool,
-) -> fbuild_core::Result<()> {
- let project_dir = normalize_path(&project_dir).await?;
- let project_path = std::path::Path::new(&project_dir);
-
- // Step 1: Resolve the environment name (explicit -e wins, else default).
- let env_name = resolve_env_name(project_path, environment)?;
- output::progress(format!("Using environment: {}", env_name));
-
- // Step 2: Ensure compile_commands.json exists at the project root.
- let db_path = project_path.join("compile_commands.json");
- if db_path.exists() {
- output::progress("Using existing compile_commands.json");
- } else {
- output::progress("Generating compile_commands.json...");
- run_build(
- project_dir.clone(),
- Some(env_name.clone()),
- false, // clean
- false, // clean_all
- verbose,
- None, // jobs
- false, // quick
- false, // release
- false, // dry_run
- Some("compiledb".to_string()),
- None,
- true, // no_timestamp
- None,
- false, // bloat_analysis
- )
- .await?;
- if !db_path.exists() {
- return Err(fbuild_core::FbuildError::Other(
- "compile_commands.json was not generated".into(),
- ));
- }
- }
-
- // Step 3: Pull the real cross-compiler path out of the compile database.
- let compiler = extract_compiler_path(&db_path)?;
- let query_driver = compiler_query_driver_glob(&compiler);
- output::progress(format!("Detected compiler: {}", compiler));
-
- // Step 4: Write .clangd
- let clangd_path = project_path.join(".clangd");
- std::fs::write(&clangd_path, render_clangd_yaml(&compiler)).map_err(|e| {
- fbuild_core::FbuildError::Other(format!("failed to write {}: {}", clangd_path.display(), e))
- })?;
-
- // Step 5: Write/merge .vscode/settings.json (only the clangd-related keys).
- let vscode_dir = project_path.join(".vscode");
- std::fs::create_dir_all(&vscode_dir).map_err(|e| {
- fbuild_core::FbuildError::Other(format!("failed to create {}: {}", vscode_dir.display(), e))
- })?;
- let settings_path = vscode_dir.join("settings.json");
- let merged_settings = merge_vscode_settings(&settings_path, &query_driver)?;
- std::fs::write(&settings_path, merged_settings).map_err(|e| {
- fbuild_core::FbuildError::Other(format!(
- "failed to write {}: {}",
- settings_path.display(),
- e
- ))
- })?;
-
- // Step 6: Write .vscode/extensions.json only if it does not already exist.
- // Atomic write — FastLED/fbuild#844 bridge pair 6 (state-file write).
- let extensions_path = vscode_dir.join("extensions.json");
- let wrote_extensions = if extensions_path.exists() {
- false
- } else {
- fbuild_core::fs::write_atomic(&extensions_path, render_extensions_json())
- .await
- .map_err(|e| {
- fbuild_core::FbuildError::Other(format!(
- "failed to write {}: {}",
- extensions_path.display(),
- e
- ))
- })?;
- true
- };
-
- // Step 7: Summary.
- output::result("\nWrote clangd configuration:");
- output::result(format!(" {}", db_path.display()));
- output::result(format!(" {}", clangd_path.display()));
- output::result(format!(" {}", settings_path.display()));
- if wrote_extensions {
- output::result(format!(" {}", extensions_path.display()));
- } else {
- output::result(format!(
- " {} (left unchanged — already exists)",
- extensions_path.display()
- ));
- }
- output::result("\nInstall the clangd extension (llvm-vs-code-extensions.vscode-clangd),");
- output::result(
- "then run \"clangd: Restart language server\" in VS Code to pick up the config.",
- );
-
- Ok(())
-}
-
-/// Resolve the environment name: explicit `-e` wins, otherwise fall back to the
-/// project's default environment (PLATFORMIO_DEFAULT_ENVS → `[platformio]
-/// default_envs` → first env in file order).
-fn resolve_env_name(
- project_path: &std::path::Path,
- environment: Option,
-) -> fbuild_core::Result {
- if let Some(env) = environment {
- 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(),
- )
- })
-}
-
-/// Extract the absolute cross-compiler path from the first entry of a
-/// `compile_commands.json`. The compile database records the real GCC/G++
-/// binary (not a cache wrapper) as `arguments[0]`.
-fn extract_compiler_path(db_path: &std::path::Path) -> fbuild_core::Result {
- let content = std::fs::read_to_string(db_path).map_err(|e| {
- fbuild_core::FbuildError::Other(format!("failed to read compile_commands.json: {}", e))
- })?;
- let entries: Vec = serde_json::from_str(&content).map_err(|e| {
- fbuild_core::FbuildError::Other(format!("failed to parse compile_commands.json: {}", e))
- })?;
- compiler_from_entries(&entries).ok_or_else(|| {
- fbuild_core::FbuildError::Other(
- "could not determine compiler path from compile_commands.json".into(),
- )
- })
-}
-
-/// Pull `arguments[0]` (or the first token of `command`) from the first entry.
-fn compiler_from_entries(entries: &[serde_json::Value]) -> Option {
- let entry = entries.first()?;
- if let Some(args) = entry.get("arguments").and_then(|a| a.as_array()) {
- if let Some(first) = args.first().and_then(|v| v.as_str()) {
- if !first.is_empty() {
- return Some(first.to_string());
- }
- }
- }
- // Fallback: some databases use a single "command" string.
- if let Some(cmd) = entry.get("command").and_then(|c| c.as_str()) {
- if let Some(first) = cmd.split_whitespace().next() {
- if !first.is_empty() {
- return Some(first.to_string());
- }
- }
- }
- None
-}
-
-/// Build a `--query-driver` glob for clangd from a compiler path: the
-/// compiler's `bin/` directory plus `/*`, with forward slashes (clangd-friendly
-/// on Windows too).
-fn compiler_query_driver_glob(compiler: &str) -> String {
- // FastLED/fbuild#911 — path-shape slash normalization goes through
- // `NormalizedPath::display_slash()`.
- let normalized = fbuild_core::path::NormalizedPath::from(compiler).display_slash();
- let bin_dir = match normalized.rfind('/') {
- Some(idx) => &normalized[..idx],
- None => ".",
- };
- format!("{}/*", bin_dir)
-}
-
-/// Render the `.clangd` YAML, pinning the compilation database to the project
-/// root and trusting the build's real compiler.
-fn render_clangd_yaml(compiler: &str) -> String {
- // FastLED/fbuild#911 — path-shape slash normalization goes through
- // `NormalizedPath::display_slash()`.
- let compiler_fwd = fbuild_core::path::NormalizedPath::from(compiler).display_slash();
- format!(
- "# Generated by `fbuild clangd-config` — safe to edit, regenerate to refresh.\n\
-CompileFlags:\n\
-\x20\x20CompilationDatabase: .\n\
-\x20\x20# Trust the build's compiler instead of clangd's default driver guess.\n\
-\x20\x20Compiler: {compiler}\n\
-Diagnostics:\n\
-\x20\x20# Many embedded toolchains emit flags clangd cannot parse cleanly.\n\
-\x20\x20Suppress: [drv_unknown_argument, unknown-warning-option]\n",
- compiler = compiler_fwd
- )
-}
-
-/// Render the recommended-extensions JSON.
-fn render_extensions_json() -> String {
- "{\n \"recommendations\": [\n \"llvm-vs-code-extensions.vscode-clangd\"\n ]\n}\n"
- .to_string()
-}
-
-/// Merge clangd-related keys into a (possibly pre-existing) `.vscode/settings.json`,
-/// preserving any unrelated keys. Only the clangd / MS-extension keys are updated.
-fn merge_vscode_settings(
- settings_path: &std::path::Path,
- query_driver: &str,
-) -> fbuild_core::Result {
- let mut root: serde_json::Map = if settings_path.exists() {
- let content = std::fs::read_to_string(settings_path).map_err(|e| {
- fbuild_core::FbuildError::Other(format!(
- "failed to read {}: {}",
- settings_path.display(),
- e
- ))
- })?;
- // Tolerate an empty/whitespace file as an empty object.
- if content.trim().is_empty() {
- serde_json::Map::new()
- } else {
- serde_json::from_str(&content).map_err(|e| {
- fbuild_core::FbuildError::Other(format!(
- "failed to parse {} as JSON: {}",
- settings_path.display(),
- e
- ))
- })?
- }
- } else {
- serde_json::Map::new()
- };
-
- root.insert(
- "C_Cpp.intelliSenseEngine".into(),
- serde_json::Value::String("disabled".into()),
- );
- root.insert(
- "C_Cpp.autoAddFileAssociations".into(),
- serde_json::Value::Bool(false),
- );
- root.insert(
- "clangd.arguments".into(),
- serde_json::Value::Array(
- clangd_arguments(query_driver)
- .into_iter()
- .map(serde_json::Value::String)
- .collect(),
- ),
- );
-
- let mut out = serde_json::to_string_pretty(&serde_json::Value::Object(root)).map_err(|e| {
- fbuild_core::FbuildError::Other(format!("failed to serialize settings.json: {}", e))
- })?;
- out.push('\n');
- Ok(out)
-}
-
-/// The clangd argument list written into `.vscode/settings.json`.
-fn clangd_arguments(query_driver: &str) -> Vec {
- vec![
- "--compile-commands-dir=${workspaceFolder}".to_string(),
- format!("--query-driver={}", query_driver),
- "--background-index".to_string(),
- "--clang-tidy".to_string(),
- "--header-insertion=never".to_string(),
- "--completion-style=detailed".to_string(),
- ]
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn compiler_from_arguments_first_token() {
- let entries: Vec = serde_json::from_str(
- r#"[{"file":"a.cpp","arguments":["/tc/bin/avr-g++","-c","a.cpp"]}]"#,
- )
- .unwrap();
- assert_eq!(
- compiler_from_entries(&entries).as_deref(),
- Some("/tc/bin/avr-g++")
- );
- }
-
- #[test]
- fn compiler_from_command_string_fallback() {
- let entries: Vec = serde_json::from_str(
- r#"[{"file":"a.cpp","command":"/tc/bin/xtensa-esp32-elf-gcc -c a.cpp"}]"#,
- )
- .unwrap();
- assert_eq!(
- compiler_from_entries(&entries).as_deref(),
- Some("/tc/bin/xtensa-esp32-elf-gcc")
- );
- }
-
- // The Windows-input arms of these two tests feed a raw `C:\tc\...`
- // literal through `NormalizedPath::display_slash()`, which only
- // converts `\` → `/` on Windows targets. On Linux, `\` is a valid
- // filename byte, so the normalizer leaves it alone and the asserted
- // `"C:/tc/bin/*"` shape never materializes. Gate the Windows arms
- // behind `#[cfg(windows)]` and keep the POSIX arm portable.
- #[test]
- fn query_driver_glob_uses_bin_dir_forward_slashes_posix() {
- assert_eq!(
- compiler_query_driver_glob("/home/u/.platformio/packages/tc/bin/arm-none-eabi-g++"),
- "/home/u/.platformio/packages/tc/bin/*"
- );
- }
-
- #[cfg(windows)]
- #[test]
- fn query_driver_glob_uses_bin_dir_forward_slashes_windows() {
- assert_eq!(
- compiler_query_driver_glob(r"C:\tc\bin\avr-g++.exe"),
- "C:/tc/bin/*"
- );
- }
-
- #[test]
- fn clangd_yaml_mentions_compiler_and_database() {
- let yaml = render_clangd_yaml("/tc/bin/avr-g++");
- assert!(yaml.contains("CompilationDatabase: ."));
- assert!(yaml.contains("Compiler: /tc/bin/avr-g++"));
- }
-
- #[test]
- #[cfg(windows)]
- fn clangd_yaml_rewrites_windows_backslashes() {
- let yaml = render_clangd_yaml(r"C:\tc\bin\avr-g++");
- assert!(yaml.contains("Compiler: C:/tc/bin/avr-g++"));
- }
-
- #[test]
- fn merge_preserves_unrelated_keys_and_sets_clangd() {
- let tmp = tempfile::tempdir().unwrap();
- let settings = tmp.path().join("settings.json");
- std::fs::write(
- &settings,
- r#"{"editor.tabSize": 2, "files.trimTrailingWhitespace": true}"#,
- )
- .unwrap();
-
- let merged = merge_vscode_settings(&settings, "C:/tc/bin/*").unwrap();
- let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
-
- // Unrelated keys preserved.
- assert_eq!(parsed["editor.tabSize"], serde_json::json!(2));
- assert_eq!(
- parsed["files.trimTrailingWhitespace"],
- serde_json::json!(true)
- );
- // clangd keys set.
- assert_eq!(
- parsed["C_Cpp.intelliSenseEngine"],
- serde_json::json!("disabled")
- );
- let args = parsed["clangd.arguments"].as_array().unwrap();
- assert!(
- args.iter()
- .any(|a| a.as_str() == Some("--query-driver=C:/tc/bin/*"))
- );
- assert!(
- args.iter()
- .any(|a| a.as_str() == Some("--compile-commands-dir=${workspaceFolder}"))
- );
- }
-
- #[test]
- fn merge_is_idempotent() {
- let tmp = tempfile::tempdir().unwrap();
- let settings = tmp.path().join("settings.json");
-
- let first = merge_vscode_settings(&settings, "C:/tc/bin/*").unwrap();
- std::fs::write(&settings, &first).unwrap();
- let second = merge_vscode_settings(&settings, "C:/tc/bin/*").unwrap();
- assert_eq!(first, second);
- }
-
- #[test]
- fn merge_tolerates_empty_existing_file() {
- let tmp = tempfile::tempdir().unwrap();
- let settings = tmp.path().join("settings.json");
- std::fs::write(&settings, " \n").unwrap();
- let merged = merge_vscode_settings(&settings, "C:/tc/bin/*").unwrap();
- let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
- assert_eq!(
- parsed["C_Cpp.intelliSenseEngine"],
- serde_json::json!("disabled")
- );
- }
-}
diff --git a/crates/fbuild-cli/src/cli/clangd_config/README.md b/crates/fbuild-cli/src/cli/clangd_config/README.md
new file mode 100644
index 000000000..0cf5a4c23
--- /dev/null
+++ b/crates/fbuild-cli/src/cli/clangd_config/README.md
@@ -0,0 +1,35 @@
+# clangd_config
+
+`fbuild clangd-config`: emits an IDE-ready clangd configuration for a
+project's PlatformIO environment. Split into an editor-neutral core plus
+per-editor emitters (FastLED/fbuild#1076 Phase 0) so the core is reusable by
+the planned `fbuild ide` command.
+
+## Modules
+
+- **`mod.rs`** -- `run_clangd_config` entry point, `Editor` selection
+ (`--editor vscode|zed`), `.clangd` emission (shared/editor-neutral),
+ compile-DB freshness (`ensure_compile_db`, gated by `--refresh`), and
+ `emit_editor_config` dispatch. `ensure_compile_db`, `emit_clangd_file`, and
+ `emit_editor_config` are `pub(crate)` so the future `fbuild ide` module can
+ call them directly.
+- **`vscode.rs`** -- VS Code emitter: merge-don't-clobber
+ `.vscode/settings.json` (clangd args including
+ `--compile-commands-dir=${workspaceFolder}`) and write-once
+ `.vscode/extensions.json`.
+- **`zed.rs`** -- Zed emitter: merge-don't-clobber `.zed/settings.json`
+ (`file_types."C++"` += `"ino"`, `lsp.clangd.binary.arguments` without
+ `--compile-commands-dir` — Zed has no `${workspaceFolder}`-style variable,
+ so it relies on `.clangd`'s `CompilationDatabase: .` instead).
+
+## Why `.clangd` has no `Compiler:` pin
+
+Earlier versions pinned `Compiler:` in `.clangd` and asked clangd to
+`--query-driver` the real cross-compiler for its builtin include search
+paths. That path had silently degenerated to a no-op glob:
+`CompileDatabase::translate_for_clang` (in `fbuild-build-engine`) rewrites
+`arguments[0]` to bare `clang`/`clang++` before the database is written, so
+`extract_compiler_path` could only ever recover `"clang++"`. Phase 0 deleted
+that machinery and instead bakes the toolchain's GCC builtin include dirs
+into every translated entry as `-isystem` args — see
+`crates/fbuild-build-engine/src/compile_database/clang.rs`.
diff --git a/crates/fbuild-cli/src/cli/clangd_config/mod.rs b/crates/fbuild-cli/src/cli/clangd_config/mod.rs
new file mode 100644
index 000000000..3da955d3f
--- /dev/null
+++ b/crates/fbuild-cli/src/cli/clangd_config/mod.rs
@@ -0,0 +1,278 @@
+//! `fbuild clangd-config`: emit an IDE-ready clangd configuration for a
+//! project's default (or chosen) PlatformIO environment so that "Go to
+//! Definition", header hover, and include resolution work in clangd-backed
+//! editors without any manual setup.
+//!
+//! The command sits on top of the existing `compile_database` machinery: it
+//! ensures `compile_commands.json` exists (via `build -t compiledb`) and
+//! writes the editor-neutral `.clangd` file plus per-editor project config
+//! (`.vscode/*` or `.zed/*`). It does not touch the build pipeline.
+//!
+//! ## Editor-neutral core + per-editor emitters (FastLED/fbuild#1076 Phase 0)
+//!
+//! This module is split so the core (env resolution, compile-DB
+//! freshness, `.clangd` emission) is reusable by any future editor and by
+//! the planned `fbuild ide` command:
+//!
+//! - `mod.rs` (this file) — `Editor` selection, `ensure_compile_db`,
+//! `emit_clangd_file`, `emit_editor_config` dispatch. These three
+//! `pub(crate)` functions are the reusable core surface.
+//! - `vscode` — the VS Code emitter (`.vscode/settings.json`,
+//! `.vscode/extensions.json`). This is the original (and default)
+//! behavior of `fbuild clangd-config`.
+//! - `zed` — the Zed emitter (`.zed/settings.json`), first cut for the
+//! Phase 1 `fbuild ide` MVP on stock Zed.
+//!
+//! `.clangd` itself is intentionally emitted once, here, and shared between
+//! editors — `CompilationDatabase: .` plus diagnostic suppression is
+//! editor-neutral. It no longer pins a `Compiler:` path or asks clangd to
+//! `--query-driver` one: the cross-compiler's builtin include dirs are now
+//! baked into `compile_commands.json` itself as `-isystem` args by
+//! `CompileDatabase::translate_for_clang` (the query-driver path degenerated
+//! to a no-op glob once `translate_for_clang` started rewriting
+//! `arguments[0]` to bare `clang`/`clang++` — see
+//! `crates/fbuild-build-engine/src/compile_database/clang.rs`).
+
+mod vscode;
+mod zed;
+
+use crate::output;
+
+use super::build::{normalize_path, run_build};
+
+/// Which editor to emit per-editor project config for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Editor {
+ VsCode,
+ Zed,
+}
+
+impl Editor {
+ /// Parse a `--editor` value. Callers gate the accepted strings via
+ /// clap's `value_parser = ["vscode", "zed"]`, so this always succeeds
+ /// for CLI-originated input; the fallback exists so a stray internal
+ /// caller degrades to the (safe, original) default instead of panicking.
+ fn parse(value: &str) -> Editor {
+ match value {
+ "zed" => Editor::Zed,
+ _ => Editor::VsCode,
+ }
+ }
+
+ fn label(self) -> &'static str {
+ match self {
+ Editor::VsCode => "VS Code",
+ Editor::Zed => "Zed",
+ }
+ }
+}
+
+/// Generate clangd / editor configuration for the project's default env.
+pub async fn run_clangd_config(
+ project_dir: String,
+ environment: Option,
+ verbose: bool,
+ editor: String,
+ refresh: bool,
+) -> fbuild_core::Result<()> {
+ let editor = Editor::parse(&editor);
+ let project_dir = normalize_path(&project_dir).await?;
+ let project_path = std::path::Path::new(&project_dir);
+
+ // Step 1: Resolve the environment name (explicit -e wins, else default).
+ let env_name = resolve_env_name(project_path, environment)?;
+ output::progress(format!("Using environment: {}", env_name));
+
+ // Step 2: Ensure compile_commands.json exists (and is fresh) at the
+ // project root.
+ let db_path =
+ ensure_compile_db(&project_dir, project_path, &env_name, verbose, refresh).await?;
+
+ // Step 3: Write the shared, editor-neutral .clangd file.
+ let clangd_path = emit_clangd_file(project_path)?;
+
+ // Step 4: Write the per-editor project config.
+ let editor_paths = emit_editor_config(editor, project_path)?;
+
+ // Step 5: Summary.
+ output::result("\nWrote clangd configuration:");
+ output::result(format!(" {}", db_path.display()));
+ output::result(format!(" {}", clangd_path.display()));
+ for (path, written) in &editor_paths {
+ if *written {
+ output::result(format!(" {}", path.display()));
+ } else {
+ output::result(format!(
+ " {} (left unchanged — already exists)",
+ path.display()
+ ));
+ }
+ }
+ output::result(format!(
+ "\nInstall the clangd extension for {}, then restart its language server to pick up the config.",
+ editor.label()
+ ));
+
+ Ok(())
+}
+
+/// Resolve the environment name: explicit `-e` wins, otherwise fall back to the
+/// project's default environment (PLATFORMIO_DEFAULT_ENVS → `[platformio]
+/// default_envs` → first env in file order).
+fn resolve_env_name(
+ project_path: &std::path::Path,
+ environment: Option,
+) -> fbuild_core::Result {
+ if let Some(env) = environment {
+ 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(),
+ )
+ })
+}
+
+/// Ensure `compile_commands.json` exists at the project root, regenerating
+/// it via `fbuild build -t compiledb` when missing — or, with
+/// `refresh: true`, unconditionally (FastLED/fbuild#1076 Phase 0 item 3:
+/// "regeneration must be a first-class cheap operation" that the future
+/// `fbuild ide` module can call on open / env-switch / after a build).
+///
+/// `pub(crate)` so the planned `fbuild ide` module (FastLED/fbuild#1076
+/// Phase 1) can reuse it directly.
+pub(crate) async fn ensure_compile_db(
+ project_dir: &str,
+ project_path: &std::path::Path,
+ env_name: &str,
+ verbose: bool,
+ refresh: bool,
+) -> fbuild_core::Result {
+ let db_path = project_path.join("compile_commands.json");
+ if !refresh && db_path.exists() {
+ output::progress("Using existing compile_commands.json");
+ return Ok(db_path);
+ }
+
+ output::progress("Generating compile_commands.json...");
+ run_build(
+ project_dir.to_string(),
+ Some(env_name.to_string()),
+ false, // clean
+ false, // clean_all
+ verbose,
+ None, // jobs
+ false, // quick
+ false, // release
+ false, // dry_run
+ Some("compiledb".to_string()),
+ None,
+ true, // no_timestamp
+ None,
+ false, // bloat_analysis
+ )
+ .await?;
+ if !db_path.exists() {
+ return Err(fbuild_core::FbuildError::Other(
+ "compile_commands.json was not generated".into(),
+ ));
+ }
+ Ok(db_path)
+}
+
+/// Write the shared, editor-neutral `.clangd` file. `pub(crate)` so the
+/// future `fbuild ide` module can reuse it directly.
+pub(crate) fn emit_clangd_file(
+ project_path: &std::path::Path,
+) -> fbuild_core::Result {
+ let clangd_path = project_path.join(".clangd");
+ std::fs::write(&clangd_path, render_clangd_yaml()).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!("failed to write {}: {}", clangd_path.display(), e))
+ })?;
+ Ok(clangd_path)
+}
+
+/// Render the `.clangd` YAML, pinning the compilation database to the
+/// project root. No longer pins a `Compiler:` path or requests
+/// `--query-driver` (FastLED/fbuild#1076 Phase 0 — the toolchain's builtin
+/// include dirs are baked into `compile_commands.json` as `-isystem`
+/// instead; the query-driver path had already silently degenerated to a
+/// no-op glob).
+fn render_clangd_yaml() -> String {
+ "# Generated by `fbuild clangd-config` — safe to edit, regenerate to refresh.\n\
+CompileFlags:\n\
+\x20\x20CompilationDatabase: .\n\
+Diagnostics:\n\
+\x20\x20# Many embedded toolchains emit flags clangd cannot parse cleanly.\n\
+\x20\x20Suppress: [drv_unknown_argument, unknown-warning-option]\n"
+ .to_string()
+}
+
+/// clangd arguments common to every editor. VS Code additionally prepends
+/// `--compile-commands-dir=${workspaceFolder}`; Zed has no such variable and
+/// relies on `.clangd`'s `CompilationDatabase: .` instead (editor-neutral,
+/// works from any working directory Zed launches clangd in).
+pub(crate) fn shared_clangd_arguments() -> Vec {
+ vec![
+ "--background-index".to_string(),
+ "--clang-tidy".to_string(),
+ "--header-insertion=never".to_string(),
+ "--completion-style=detailed".to_string(),
+ ]
+}
+
+/// Dispatch to the per-editor emitter. Returns `(path, was_written)` pairs
+/// for the summary output — `was_written` is `false` for files intentionally
+/// left untouched (e.g. `.vscode/extensions.json` when it already exists).
+/// `pub(crate)` so the future `fbuild ide` module can reuse it directly.
+pub(crate) fn emit_editor_config(
+ editor: Editor,
+ project_path: &std::path::Path,
+) -> fbuild_core::Result> {
+ match editor {
+ Editor::VsCode => vscode::emit(project_path),
+ Editor::Zed => zed::emit(project_path),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn editor_parse_recognizes_zed_and_defaults_to_vscode() {
+ assert_eq!(Editor::parse("zed"), Editor::Zed);
+ assert_eq!(Editor::parse("vscode"), Editor::VsCode);
+ assert_eq!(Editor::parse("anything-else"), Editor::VsCode);
+ }
+
+ #[test]
+ fn clangd_yaml_no_longer_pins_compiler_or_query_driver() {
+ let yaml = render_clangd_yaml();
+ assert!(yaml.contains("CompilationDatabase: ."));
+ assert!(!yaml.contains("Compiler:"));
+ assert!(!yaml.contains("query-driver"));
+ }
+
+ #[test]
+ fn shared_clangd_arguments_have_no_workspace_folder_variable() {
+ // Zed has no `${workspaceFolder}`-style variable — only VS Code's
+ // emitter may add `--compile-commands-dir=${workspaceFolder}`.
+ assert!(
+ shared_clangd_arguments()
+ .iter()
+ .all(|a| !a.contains("${workspaceFolder}"))
+ );
+ }
+}
diff --git a/crates/fbuild-cli/src/cli/clangd_config/vscode.rs b/crates/fbuild-cli/src/cli/clangd_config/vscode.rs
new file mode 100644
index 000000000..6b1d9a58a
--- /dev/null
+++ b/crates/fbuild-cli/src/cli/clangd_config/vscode.rs
@@ -0,0 +1,209 @@
+//! VS Code emitter: `.vscode/settings.json` (merge-don't-clobber) and
+//! `.vscode/extensions.json` (write-once).
+
+use std::path::{Path, PathBuf};
+
+/// Write/merge `.vscode/settings.json` and (if absent) `.vscode/extensions.json`.
+/// Returns `(path, was_written)` pairs for the caller's summary output.
+pub(super) fn emit(project_path: &Path) -> fbuild_core::Result> {
+ let vscode_dir = project_path.join(".vscode");
+ std::fs::create_dir_all(&vscode_dir).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!("failed to create {}: {}", vscode_dir.display(), e))
+ })?;
+
+ let settings_path = vscode_dir.join("settings.json");
+ let merged_settings = merge_vscode_settings(&settings_path)?;
+ std::fs::write(&settings_path, merged_settings).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to write {}: {}",
+ settings_path.display(),
+ e
+ ))
+ })?;
+
+ // Atomic write — FastLED/fbuild#844 bridge pair 6 (state-file write).
+ let extensions_path = vscode_dir.join("extensions.json");
+ let wrote_extensions = if extensions_path.exists() {
+ false
+ } else {
+ fbuild_core::fs::write_atomic_sync(&extensions_path, render_extensions_json()).map_err(
+ |e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to write {}: {}",
+ extensions_path.display(),
+ e
+ ))
+ },
+ )?;
+ true
+ };
+
+ Ok(vec![
+ (settings_path, true),
+ (extensions_path, wrote_extensions),
+ ])
+}
+
+/// Render the recommended-extensions JSON.
+fn render_extensions_json() -> String {
+ "{\n \"recommendations\": [\n \"llvm-vs-code-extensions.vscode-clangd\"\n ]\n}\n"
+ .to_string()
+}
+
+/// Merge clangd-related keys into a (possibly pre-existing) `.vscode/settings.json`,
+/// preserving any unrelated keys. Only the clangd / MS-extension keys are updated.
+fn merge_vscode_settings(settings_path: &Path) -> fbuild_core::Result {
+ let mut root: serde_json::Map = if settings_path.exists() {
+ let content = std::fs::read_to_string(settings_path).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to read {}: {}",
+ settings_path.display(),
+ e
+ ))
+ })?;
+ // Tolerate an empty/whitespace file as an empty object.
+ if content.trim().is_empty() {
+ serde_json::Map::new()
+ } else {
+ serde_json::from_str(&content).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to parse {} as JSON: {}",
+ settings_path.display(),
+ e
+ ))
+ })?
+ }
+ } else {
+ serde_json::Map::new()
+ };
+
+ root.insert(
+ "C_Cpp.intelliSenseEngine".into(),
+ serde_json::Value::String("disabled".into()),
+ );
+ root.insert(
+ "C_Cpp.autoAddFileAssociations".into(),
+ serde_json::Value::Bool(false),
+ );
+ root.insert(
+ "clangd.arguments".into(),
+ serde_json::Value::Array(
+ clangd_arguments()
+ .into_iter()
+ .map(serde_json::Value::String)
+ .collect(),
+ ),
+ );
+
+ let mut out = serde_json::to_string_pretty(&serde_json::Value::Object(root)).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!("failed to serialize settings.json: {}", e))
+ })?;
+ out.push('\n');
+ Ok(out)
+}
+
+/// The clangd argument list written into `.vscode/settings.json`. VS Code
+/// (unlike Zed) has a `${workspaceFolder}` variable, so it can point clangd
+/// at the compile database explicitly rather than relying solely on
+/// `.clangd`'s `CompilationDatabase: .`.
+fn clangd_arguments() -> Vec {
+ let mut args = vec!["--compile-commands-dir=${workspaceFolder}".to_string()];
+ args.extend(super::shared_clangd_arguments());
+ args
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn merge_preserves_unrelated_keys_and_sets_clangd() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+ std::fs::write(
+ &settings,
+ r#"{"editor.tabSize": 2, "files.trimTrailingWhitespace": true}"#,
+ )
+ .unwrap();
+
+ let merged = merge_vscode_settings(&settings).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
+
+ // Unrelated keys preserved.
+ assert_eq!(parsed["editor.tabSize"], serde_json::json!(2));
+ assert_eq!(
+ parsed["files.trimTrailingWhitespace"],
+ serde_json::json!(true)
+ );
+ // clangd keys set.
+ assert_eq!(
+ parsed["C_Cpp.intelliSenseEngine"],
+ serde_json::json!("disabled")
+ );
+ let args = parsed["clangd.arguments"].as_array().unwrap();
+ assert!(
+ args.iter()
+ .any(|a| a.as_str() == Some("--compile-commands-dir=${workspaceFolder}"))
+ );
+ // The degenerate --query-driver argument is gone (FastLED/fbuild#1076
+ // Phase 0 — builtin include dirs are baked into the DB instead).
+ assert!(
+ !args
+ .iter()
+ .any(|a| a.as_str().is_some_and(|s| s.starts_with("--query-driver")))
+ );
+ }
+
+ #[test]
+ fn merge_is_idempotent() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+
+ let first = merge_vscode_settings(&settings).unwrap();
+ std::fs::write(&settings, &first).unwrap();
+ let second = merge_vscode_settings(&settings).unwrap();
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn merge_tolerates_empty_existing_file() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+ std::fs::write(&settings, " \n").unwrap();
+ let merged = merge_vscode_settings(&settings).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
+ assert_eq!(
+ parsed["C_Cpp.intelliSenseEngine"],
+ serde_json::json!("disabled")
+ );
+ }
+
+ #[test]
+ fn emit_writes_settings_and_extensions_into_new_project() {
+ let tmp = tempfile::tempdir().unwrap();
+ let written = emit(tmp.path()).unwrap();
+ assert_eq!(written.len(), 2);
+ assert!(written.iter().all(|(_, was_written)| *was_written));
+ assert!(tmp.path().join(".vscode/settings.json").exists());
+ assert!(tmp.path().join(".vscode/extensions.json").exists());
+ }
+
+ #[test]
+ fn emit_leaves_existing_extensions_json_untouched() {
+ let tmp = tempfile::tempdir().unwrap();
+ let vscode_dir = tmp.path().join(".vscode");
+ std::fs::create_dir_all(&vscode_dir).unwrap();
+ std::fs::write(vscode_dir.join("extensions.json"), "{}\n").unwrap();
+
+ let written = emit(tmp.path()).unwrap();
+ let extensions_entry = written
+ .iter()
+ .find(|(p, _)| p.ends_with("extensions.json"))
+ .unwrap();
+ assert!(!extensions_entry.1);
+ assert_eq!(
+ std::fs::read_to_string(vscode_dir.join("extensions.json")).unwrap(),
+ "{}\n"
+ );
+ }
+}
diff --git a/crates/fbuild-cli/src/cli/clangd_config/zed.rs b/crates/fbuild-cli/src/cli/clangd_config/zed.rs
new file mode 100644
index 000000000..3198d415a
--- /dev/null
+++ b/crates/fbuild-cli/src/cli/clangd_config/zed.rs
@@ -0,0 +1,218 @@
+//! Zed emitter: `.zed/settings.json` (merge-don't-clobber), first cut for
+//! the Phase 1 `fbuild ide` MVP on stock Zed (FastLED/fbuild#1076).
+//!
+//! Zed has no `${workspaceFolder}`-style variable in `lsp` config (unlike VS
+//! Code), so the clangd arguments emitted here omit
+//! `--compile-commands-dir` entirely and rely on the shared `.clangd` file's
+//! `CompilationDatabase: .` — which is editor-neutral and always resolves
+//! relative to wherever clangd's working directory is (the project root
+//! Zed launches it from).
+
+use std::path::{Path, PathBuf};
+
+/// Write/merge `.zed/settings.json`. Returns `(path, was_written)` pairs
+/// for the caller's summary output — always `true` here since the merge
+/// itself is the "write" (there is no separate write-once file like VS
+/// Code's `extensions.json`).
+pub(super) fn emit(project_path: &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 settings_path = zed_dir.join("settings.json");
+ let merged = merge_zed_settings(&settings_path)?;
+ std::fs::write(&settings_path, merged).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to write {}: {}",
+ settings_path.display(),
+ e
+ ))
+ })?;
+
+ Ok(vec![(settings_path, true)])
+}
+
+/// Merge `file_types` (map `.ino` to the C++ language so clangd starts on
+/// sketch buffers) and `lsp.clangd.binary.arguments` into a (possibly
+/// pre-existing) `.zed/settings.json`, preserving every unrelated key —
+/// including other extensions already mapped under `"C++"` and other
+/// languages under `file_types`.
+fn merge_zed_settings(settings_path: &Path) -> fbuild_core::Result {
+ let mut root: serde_json::Map = if settings_path.exists() {
+ let content = std::fs::read_to_string(settings_path).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to read {}: {}",
+ settings_path.display(),
+ e
+ ))
+ })?;
+ if content.trim().is_empty() {
+ serde_json::Map::new()
+ } else {
+ serde_json::from_str(&content).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!(
+ "failed to parse {} as JSON: {}",
+ settings_path.display(),
+ e
+ ))
+ })?
+ }
+ } else {
+ serde_json::Map::new()
+ };
+
+ merge_ino_file_type(&mut root);
+ merge_clangd_lsp_config(&mut root);
+
+ let mut out = serde_json::to_string_pretty(&serde_json::Value::Object(root)).map_err(|e| {
+ fbuild_core::FbuildError::Other(format!("failed to serialize settings.json: {}", e))
+ })?;
+ out.push('\n');
+ Ok(out)
+}
+
+/// Ensure `file_types."C++"` contains `"ino"`, without disturbing any other
+/// extensions already mapped there or any other language's `file_types` entry.
+fn merge_ino_file_type(root: &mut serde_json::Map) {
+ let file_types = root
+ .entry("file_types")
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
+ let Some(file_types_obj) = file_types.as_object_mut() else {
+ // Pre-existing value under "file_types" isn't an object — don't
+ // clobber whatever the user has there.
+ return;
+ };
+
+ let cpp_extensions = file_types_obj
+ .entry("C++")
+ .or_insert_with(|| serde_json::Value::Array(Vec::new()));
+ let Some(cpp_array) = cpp_extensions.as_array_mut() else {
+ return;
+ };
+
+ let has_ino = cpp_array.iter().any(|v| v.as_str() == Some("ino"));
+ if !has_ino {
+ cpp_array.push(serde_json::Value::String("ino".to_string()));
+ }
+}
+
+/// Set `lsp.clangd.binary.arguments`, preserving any other `lsp.*` server
+/// config and any other keys under `lsp.clangd` (e.g. a user-set
+/// `binary.path`).
+fn merge_clangd_lsp_config(root: &mut serde_json::Map) {
+ let lsp = root
+ .entry("lsp")
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
+ let Some(lsp_obj) = lsp.as_object_mut() else {
+ return;
+ };
+
+ let clangd = lsp_obj
+ .entry("clangd")
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
+ let Some(clangd_obj) = clangd.as_object_mut() else {
+ return;
+ };
+
+ let binary = clangd_obj
+ .entry("binary")
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
+ let Some(binary_obj) = binary.as_object_mut() else {
+ return;
+ };
+
+ binary_obj.insert(
+ "arguments".to_string(),
+ serde_json::Value::Array(
+ super::shared_clangd_arguments()
+ .into_iter()
+ .map(serde_json::Value::String)
+ .collect(),
+ ),
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn merge_preserves_unrelated_keys_and_sets_ino_and_clangd() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+ std::fs::write(
+ &settings,
+ r#"{"vim_mode": true, "file_types": {"YAML": ["yml"]}}"#,
+ )
+ .unwrap();
+
+ let merged = merge_zed_settings(&settings).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
+
+ // Unrelated top-level key preserved.
+ assert_eq!(parsed["vim_mode"], serde_json::json!(true));
+ // Unrelated file_types entry preserved.
+ assert_eq!(parsed["file_types"]["YAML"], serde_json::json!(["yml"]));
+ // "ino" mapped to C++.
+ assert_eq!(parsed["file_types"]["C++"], serde_json::json!(["ino"]));
+ // clangd arguments set, without --compile-commands-dir (no Zed
+ // ${workspaceFolder} variable to use).
+ let args = parsed["lsp"]["clangd"]["binary"]["arguments"]
+ .as_array()
+ .unwrap();
+ assert!(
+ args.iter()
+ .any(|a| a.as_str() == Some("--background-index"))
+ );
+ assert!(
+ !args
+ .iter()
+ .any(|a| a.as_str().is_some_and(|s| s.contains("workspaceFolder")))
+ );
+ }
+
+ #[test]
+ fn merge_does_not_duplicate_existing_ino_mapping() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+ std::fs::write(&settings, r#"{"file_types": {"C++": ["ino", "tpp"]}}"#).unwrap();
+
+ let merged = merge_zed_settings(&settings).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
+ let cpp = parsed["file_types"]["C++"].as_array().unwrap();
+ assert_eq!(cpp.len(), 2);
+ assert!(cpp.contains(&serde_json::json!("ino")));
+ assert!(cpp.contains(&serde_json::json!("tpp")));
+ }
+
+ #[test]
+ fn merge_is_idempotent() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+
+ let first = merge_zed_settings(&settings).unwrap();
+ std::fs::write(&settings, &first).unwrap();
+ let second = merge_zed_settings(&settings).unwrap();
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn merge_tolerates_empty_existing_file() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.json");
+ std::fs::write(&settings, " \n").unwrap();
+ let merged = merge_zed_settings(&settings).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&merged).unwrap();
+ assert_eq!(parsed["file_types"]["C++"], serde_json::json!(["ino"]));
+ }
+
+ #[test]
+ fn emit_creates_zed_dir_and_settings() {
+ let tmp = tempfile::tempdir().unwrap();
+ let written = emit(tmp.path()).unwrap();
+ assert_eq!(written.len(), 1);
+ assert!(written[0].1);
+ assert!(tmp.path().join(".zed/settings.json").exists());
+ }
+}
diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs
index ea097d2c9..90676d31b 100644
--- a/crates/fbuild-cli/src/cli/dispatch.rs
+++ b/crates/fbuild-cli/src/cli/dispatch.rs
@@ -453,9 +453,11 @@ pub async fn async_main() {
project_dir,
environment,
verbose,
+ editor,
+ refresh,
}) => {
let project_dir = resolve_project_dir(project_dir, &top_level_project_dir);
- run_clangd_config(project_dir, environment, verbose).await
+ run_clangd_config(project_dir, environment, verbose, editor, refresh).await
}
Some(Commands::TestEmu {
project_dir,
diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md
index 90c464b6e..6f2423f4a 100644
--- a/docs/DESIGN_DECISIONS.md
+++ b/docs/DESIGN_DECISIONS.md
@@ -60,7 +60,16 @@
## DD-008: compile_commands.json with Library Project Suppression
-**Decision**: Generate `compile_commands.json` after every build. Suppress the project-root copy when `library.json` exists at the project root.
+**Decision**: Generate `compile_commands.json` on demand — via `-t compiledb` / `fbuild clangd-config` (optionally `--refresh`) — not after every build. Suppress the project-root copy when `library.json` exists at the project root.
+
+> Update (FastLED/fbuild#1076 Phase 0): an earlier draft of this decision said
+> "after every build." That was never actually implemented — only
+> `compiledb_only` builds (`-t compiledb`) and `fbuild clangd-config` reach
+> the compile-DB generation gate (`pipeline/sequential.rs`,
+> `esp32/orchestrator/build.rs`). `fbuild clangd-config --refresh` is the
+> mechanism for forcing regeneration; without `--refresh` it's skipped when
+> `compile_commands.json` already exists. See
+> `crates/fbuild-cli/src/cli/clangd_config/mod.rs`.
**Context**: clangd/VS Code IntelliSense needs a `compile_commands.json` at the project root to resolve `#include` paths. The old Python fbuild generates one with trampoline paths, which breaks "Go to Definition". Library projects (e.g. FastLED) have their own meson-based `compile_commands.json` that fbuild should not overwrite.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 679cf29de..9dc7f6184 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -147,6 +147,7 @@ known limitations.
| `fbuild bloat graph --symbol ` | Render a Graphviz back-reference graph. |
| `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 clang-tidy` | Run clang-tidy against project sources. |
| `fbuild iwyu` | Run include-what-you-use analysis. |
| `fbuild clang-query` | Run a clang-query matcher. |