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. |