From ce94297c1b8f5745ee12b8beff1196b240d7ff19 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 06:53:42 -0700 Subject: [PATCH 01/10] docs: add plain-language reporting lesson --- tasks/lessons.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tasks/lessons.md b/tasks/lessons.md index 139d1040..df9cd4c8 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -25,3 +25,9 @@ **Lesson**: When replicating an upstream tool's behavior (PlatformIO, Arduino-CLI, etc.), read the source first and match its semantics exactly. Users who flip between fbuild and the upstream tool should see byte-identical output, not a "more correct" reinterpretation. **Bonus**: Path-prefix attribution beats basename matching for library resolution. A library is selected only if the walker resolves an include *into* its `include_dirs`, not because some header shares a basename. Closes #202 and #204. + +## 2026-07-15: Explain in Human Terms First (#1076) + +**Problem**: Research summaries and issue comments written as compressed engineering notes ("byte-equivalent TU", "query-driver glob degenerates to ./*", file:line citations mid-sentence) made the user ask twice: "this is hard to understand" / "tell me in human terms". + +**Rule**: Lead with what it means for a person using the tool, in plain sentences. Put the mechanism in a worked example (real sketch → what gets generated → what the editor sees) instead of abstract nouns. Keep file:line references out of prose meant for humans — collect them in a details/sources section. From 53cf328d7a3f3f3c13e2ab20d75f84a876a33c96 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 07:40:19 -0700 Subject: [PATCH 02/10] fix(ldf): root framework selection at sketch --- .../fbuild-build-engine/src/framework_libs.rs | 98 +++++++++++++++++-- crates/fbuild-cli/src/lib_select.rs | 46 ++++++++- crates/fbuild-library-select/src/cache.rs | 2 +- docs/architecture/library-selection.md | 9 ++ 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/crates/fbuild-build-engine/src/framework_libs.rs b/crates/fbuild-build-engine/src/framework_libs.rs index 743f3f2e..6aacec2b 100644 --- a/crates/fbuild-build-engine/src/framework_libs.rs +++ b/crates/fbuild-build-engine/src/framework_libs.rs @@ -174,7 +174,7 @@ pub fn resolve_framework_library_sources_from_libraries( } let seeds = collect_project_seeds(roots); - let search_paths: Vec = roots.to_vec(); + let search_paths = project_search_paths(roots); let selection = resolve_library_selection(&seeds, &search_paths, libraries); for name in &selection.required_libraries { @@ -239,7 +239,7 @@ pub(crate) fn resolve_framework_library_sources_cached_with_hit( } let seeds = collect_project_seeds(&roots); - let search_paths: Vec = roots.clone(); + let search_paths = project_search_paths(&roots); match resolve_cached(&seeds, &search_paths, &filtered, key_inputs, store) { Ok(cached) => { @@ -333,13 +333,39 @@ fn push_existing_unique(roots: &mut Vec, path: PathBuf) { } } -/// Collect every source file under each root as a walker seed. Headers are -/// intentionally included so libraries referenced only from a `.h` in the -/// project tree still get picked up. +/// Include search paths for the project and its local Arduino libraries. +/// +/// Local libraries live under `lib//` (or `lib//src/`), but the +/// `lib/` directory itself cannot resolve ``. Add each library's +/// public root while retaining the project roots ahead of framework libraries. +fn project_search_paths(roots: &[PathBuf]) -> Vec { + let mut paths = roots.to_vec(); + for root in roots { + if !is_library_root(root) { + continue; + } + let Ok(entries) = std::fs::read_dir(root) else { + continue; + }; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + push_existing_unique(&mut paths, dir.clone()); + push_existing_unique(&mut paths, dir.join("src")); + } + } + paths +} + +/// Collect translation units as walker seeds. Headers must be reached through +/// the sketch's transitive include graph; scanning every header under `lib/` +/// turns inactive library code into false framework-library dependencies. fn collect_project_seeds(roots: &[PathBuf]) -> Vec { let mut seeds = Vec::new(); for root in roots { - if !root.exists() { + if !root.exists() || is_library_root(root) { continue; } for entry in WalkDir::new(root) @@ -350,7 +376,7 @@ fn collect_project_seeds(roots: &[PathBuf]) -> Vec { if !entry.file_type().is_file() { continue; } - if is_source_or_header_file(entry.path()) { + if is_translation_unit(entry.path()) { seeds.push(entry.path().to_path_buf()); } } @@ -358,6 +384,13 @@ fn collect_project_seeds(roots: &[PathBuf]) -> Vec { seeds } +fn is_library_root(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("lib")) + .unwrap_or(false) +} + fn should_scan_entry(entry: &DirEntry) -> bool { let name = entry.file_name().to_string_lossy().to_lowercase(); !matches!( @@ -376,7 +409,7 @@ fn should_scan_entry(entry: &DirEntry) -> bool { ) } -fn is_source_or_header_file(path: &Path) -> bool { +fn is_translation_unit(path: &Path) -> bool { let ext = path .extension() .and_then(|ext| ext.to_str()) @@ -384,7 +417,7 @@ fn is_source_or_header_file(path: &Path) -> bool { .to_lowercase(); matches!( ext.as_str(), - "c" | "cpp" | "cc" | "cxx" | "s" | "ino" | "h" | "hh" | "hpp" | "hxx" + "c" | "cpp" | "cc" | "cxx" | "s" | "ino" ) } @@ -539,6 +572,53 @@ mod tests { assert_eq!(sources, vec![spi_dir.join("SPI.cpp")]); } + #[test] + fn inactive_local_library_header_cannot_select_framework_library() { + // FastLED/fbuild#1094: a header anywhere under project lib/ used to + // become an independent seed. Its inactive include then selected a + // framework library even though the sketch could not reach it. + let tmp = tempfile::TempDir::new().unwrap(); + let project_src = tmp.path().join("project").join("src"); + let project_lib = tmp.path().join("project").join("lib"); + let fastled = project_lib.join("FastLED"); + std::fs::create_dir_all(&project_src).unwrap(); + std::fs::create_dir_all(&fastled).unwrap(); + std::fs::write(project_src.join("main.cpp"), "#include \n").unwrap(); + std::fs::write(fastled.join("FastLED.h"), "#include \n").unwrap(); + std::fs::write(fastled.join("inactive_audio.h"), "#include \n").unwrap(); + + let spi_dir = tmp.path().join("framework").join("libraries").join("SPI"); + std::fs::create_dir_all(&spi_dir).unwrap(); + std::fs::write(spi_dir.join("SPI.h"), "").unwrap(); + std::fs::write(spi_dir.join("SPI.cpp"), "").unwrap(); + + let audio_dir = tmp.path().join("framework").join("libraries").join("Audio"); + std::fs::create_dir_all(&audio_dir).unwrap(); + std::fs::write(audio_dir.join("Audio.h"), "").unwrap(); + std::fs::write(audio_dir.join("Audio.cpp"), "").unwrap(); + + let libraries = vec![ + FrameworkLibrary { + name: "Audio".to_string(), + dir: audio_dir.clone(), + include_dirs: vec![audio_dir.clone()], + source_files: vec![audio_dir.join("Audio.cpp")], + }, + FrameworkLibrary { + name: "SPI".to_string(), + dir: spi_dir.clone(), + include_dirs: vec![spi_dir.clone()], + source_files: vec![spi_dir.join("SPI.cpp")], + }, + ]; + + let sources = resolve_framework_library_sources_from_libraries( + &libraries, + &[project_src, project_lib], + ); + assert_eq!(sources, vec![spi_dir.join("SPI.cpp")]); + } + #[test] fn prefers_local_library_over_framework() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/fbuild-cli/src/lib_select.rs b/crates/fbuild-cli/src/lib_select.rs index af9b62db..ac15d3b8 100644 --- a/crates/fbuild-cli/src/lib_select.rs +++ b/crates/fbuild-cli/src/lib_select.rs @@ -105,8 +105,9 @@ pub fn run(project_dir: &Path, env: Option<&str>, explain: bool, json: bool) -> let scan_roots = project_scan_roots(&project_dir); let seeds = collect_project_seeds(&scan_roots); + let search_paths = project_search_paths(&scan_roots); - let selection = resolve(&seeds, &scan_roots, &libraries); + let selection = resolve(&seeds, &search_paths, &libraries); if json { emit_json( @@ -273,9 +274,33 @@ fn project_scan_roots(project_dir: &Path) -> Vec { roots } +fn project_search_paths(roots: &[PathBuf]) -> Vec { + let mut paths = roots.to_vec(); + for root in roots { + if !is_library_root(root) { + continue; + } + let Ok(entries) = std::fs::read_dir(root) else { + continue; + }; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + push_existing_unique(&mut paths, dir.clone()); + push_existing_unique(&mut paths, dir.join("src")); + } + } + paths +} + fn collect_project_seeds(roots: &[PathBuf]) -> Vec { let mut seeds = Vec::new(); for root in roots { + if is_library_root(root) { + continue; + } for entry in WalkDir::new(root) .into_iter() .filter_entry(should_scan_entry) @@ -284,7 +309,7 @@ fn collect_project_seeds(roots: &[PathBuf]) -> Vec { if !entry.file_type().is_file() { continue; } - if is_source_or_header(entry.path()) { + if is_translation_unit(entry.path()) { seeds.push(entry.path().to_path_buf()); } } @@ -292,6 +317,19 @@ fn collect_project_seeds(roots: &[PathBuf]) -> Vec { seeds } +fn push_existing_unique(paths: &mut Vec, path: PathBuf) { + if path.exists() && !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +fn is_library_root(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("lib")) + .unwrap_or(false) +} + fn should_scan_entry(entry: &DirEntry) -> bool { let name = entry.file_name().to_string_lossy().to_lowercase(); !matches!( @@ -310,7 +348,7 @@ fn should_scan_entry(entry: &DirEntry) -> bool { ) } -fn is_source_or_header(path: &Path) -> bool { +fn is_translation_unit(path: &Path) -> bool { let ext = path .extension() .and_then(|e| e.to_str()) @@ -318,7 +356,7 @@ fn is_source_or_header(path: &Path) -> bool { .to_lowercase(); matches!( ext.as_str(), - "c" | "cpp" | "cc" | "cxx" | "s" | "ino" | "h" | "hh" | "hpp" | "hxx" + "c" | "cpp" | "cc" | "cxx" | "s" | "ino" ) } diff --git a/crates/fbuild-library-select/src/cache.rs b/crates/fbuild-library-select/src/cache.rs index a7c237d3..2a01cfb2 100644 --- a/crates/fbuild-library-select/src/cache.rs +++ b/crates/fbuild-library-select/src/cache.rs @@ -37,7 +37,7 @@ pub const SCANNER_VERSION: u32 = 1; /// Bump when the resolver's 2-pass LDF semantics change (seed expansion, /// attribution, convergence rule, etc.). -pub const LDF_MODE_VERSION: u32 = 1; +pub const LDF_MODE_VERSION: u32 = 2; /// Namespace for the library-selection file cache. pub const NAMESPACE: &str = "library-selection"; diff --git a/docs/architecture/library-selection.md b/docs/architecture/library-selection.md index 2cb6f6ab..a5e067f1 100644 --- a/docs/architecture/library-selection.md +++ b/docs/architecture/library-selection.md @@ -85,6 +85,15 @@ the same. Concrete consequences: and prefix-attributes to the SPI library — no manual allowlist needed (#202). +## Rooted at the sketch + +Only project translation units seed the include walk. Headers under a local +`lib/` tree are searched normally, but are never independent roots: a local +library header must be reached from the sketch's transitive include graph +before its framework dependencies can be selected. This prevents an inactive +header anywhere in a large library from self-selecting an unrelated framework +library (FastLED/fbuild#1094). + ## Why two-pass (not fixed-point) PlatformIO `chain` mode runs BFS from project sources, then ONE From e72df581c6f23c2b342b947bebadd4e692767ec7 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 08:12:06 -0700 Subject: [PATCH 03/10] feat(ldf): evaluate active include branches --- .../src/stm32/orchestrator/mod.rs | 10 +- .../src/teensy/orchestrator.rs | 10 +- .../fbuild-build-engine/src/framework_libs.rs | 27 +- crates/fbuild-cli/src/lib_select.rs | 9 +- crates/fbuild-header-scan/src/lib.rs | 6 +- crates/fbuild-header-scan/src/scanner.rs | 279 +++++++++++++++++- crates/fbuild-header-scan/src/walker.rs | 39 ++- .../benches/resolve_warm.rs | 3 + crates/fbuild-library-select/src/cache.rs | 48 ++- crates/fbuild-library-select/src/lib.rs | 99 ++++++- 10 files changed, 508 insertions(+), 22 deletions(-) diff --git a/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs b/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs index 00e4dd6c..64fa25b2 100644 --- a/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs +++ b/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs @@ -33,7 +33,7 @@ use fbuild_packages::{Framework, Toolchain}; use crate::compile_database::TargetArchitecture; use crate::framework_libs::{ - library_select_kv_store, resolve_framework_library_sources, + library_select_kv_store, resolve_framework_library_sources_active, resolve_framework_library_sources_cached, }; use crate::generic_arm::{ArmCompiler, ArmLinker}; @@ -146,6 +146,10 @@ impl BuildOrchestrator for Stm32Orchestrator { // STM32duino only exposes bundled libraries via this framework-level // discovery (PlatformIO's LDF does the same for `framework = arduino`). let framework_libs = framework.get_framework_libraries(); + let ldf_mcu_config = + super::mcu_config::get_stm32_config_for_mcu(&ctx.board.mcu.to_lowercase())?; + let mut ldf_defines = ctx.board.get_defines(); + ldf_defines.extend(ldf_mcu_config.defines_map()); // WHY: STM32duino targets every Cortex-M family from M0 (F0xx) up // through M7 (H7xx) but the toolchain triple is constant // (`arm-none-eabi`). The cache key already includes @@ -159,6 +163,7 @@ impl BuildOrchestrator for Stm32Orchestrator { toolchain_triple: "stm32-arm-none-eabi", framework_install_path: &framework_info.install_path, framework_version: &framework_info.version, + preprocessor_defines: &ldf_defines, }; resolve_framework_library_sources_cached( &framework_libs, @@ -168,10 +173,11 @@ impl BuildOrchestrator for Stm32Orchestrator { store, ) } - None => resolve_framework_library_sources( + None => resolve_framework_library_sources_active( &framework_libs, ¶ms.project_dir, &ctx.src_dir, + &ldf_defines, ), }; if !framework_library_sources.is_empty() { diff --git a/crates/fbuild-build-arm/src/teensy/orchestrator.rs b/crates/fbuild-build-arm/src/teensy/orchestrator.rs index 71ed8746..e8109603 100644 --- a/crates/fbuild-build-arm/src/teensy/orchestrator.rs +++ b/crates/fbuild-build-arm/src/teensy/orchestrator.rs @@ -25,7 +25,7 @@ use crate::build_fingerprint::{ use crate::compile_database::TargetArchitecture; use crate::compiler::Compiler as _; use crate::framework_libs::{ - library_select_kv_store, resolve_framework_library_sources, + library_select_kv_store, resolve_framework_library_sources_active, resolve_framework_library_sources_cached, }; use crate::pipeline; @@ -185,6 +185,10 @@ impl BuildOrchestrator for TeensyOrchestrator { .retain(|p| p.file_name().map(|f| f != "Blink.cc").unwrap_or(true)); let framework_libs = framework.get_framework_libraries(); + let ldf_mcu_config = + super::mcu_config::get_teensy_config_for_mcu(&ctx.board.mcu.to_lowercase())?; + let mut ldf_defines = ctx.board.get_defines(); + ldf_defines.extend(ldf_mcu_config.defines_map()); // WHY: Teensy 3.x/4.x and TeensyLC all share teensyduino's // arm-none-eabi toolchain — a single stable triple covers every // board this orchestrator handles. The triple feeds the cache key @@ -197,6 +201,7 @@ impl BuildOrchestrator for TeensyOrchestrator { toolchain_triple: "teensy-arm-none-eabi", framework_install_path: &framework_info.install_path, framework_version: &framework_info.version, + preprocessor_defines: &ldf_defines, }; resolve_framework_library_sources_cached( &framework_libs, @@ -206,10 +211,11 @@ impl BuildOrchestrator for TeensyOrchestrator { store, ) } - None => resolve_framework_library_sources( + None => resolve_framework_library_sources_active( &framework_libs, ¶ms.project_dir, &ctx.src_dir, + &ldf_defines, ), }; if !framework_library_sources.is_empty() { diff --git a/crates/fbuild-build-engine/src/framework_libs.rs b/crates/fbuild-build-engine/src/framework_libs.rs index 6aacec2b..622c77ca 100644 --- a/crates/fbuild-build-engine/src/framework_libs.rs +++ b/crates/fbuild-build-engine/src/framework_libs.rs @@ -12,12 +12,12 @@ //! framework libraries (FNET/Snooze/RadioHead/mbedtls on teensyLC, for //! example) stay out of the compile set. See FastLED/fbuild#205. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use fbuild_library_select::cache::{CacheKeyInputs, FileKvStore, resolve_cached}; -use fbuild_library_select::resolve as resolve_library_selection; +use fbuild_library_select::{resolve as resolve_library_selection, resolve_active as resolve_active_library_selection}; use fbuild_packages::library::FrameworkLibrary; use walkdir::{DirEntry, WalkDir}; @@ -32,6 +32,20 @@ pub fn resolve_framework_library_sources( resolve_framework_library_sources_from_libraries(&filtered, &roots) } +/// Resolve framework libraries using active preprocessor branches only. +pub fn resolve_framework_library_sources_active( + libraries: &[FrameworkLibrary], + project_dir: &Path, + src_dir: &Path, + defines: &HashMap, +) -> Vec { + let roots = framework_include_scan_roots(project_dir, src_dir); + let filtered = filter_framework_libs_shadowed_by_project(libraries, &roots); + let seeds = collect_project_seeds(&roots); + let search_paths = project_search_paths(&roots); + resolve_active_library_selection(&seeds, &search_paths, &filtered, defines).source_files +} + /// Drop framework libraries whose primary header (`.h`) is /// shadowed by a same-basename header anywhere under the supplied /// `shadowing_roots`. See FastLED/fbuild#263. @@ -266,7 +280,12 @@ pub(crate) fn resolve_framework_library_sources_cached_with_hit( "library-select cache backend error; falling back to uncached resolve" ); ( - resolve_framework_library_sources_from_libraries(&filtered, &roots), + resolve_framework_library_sources_active( + &filtered, + project_dir, + src_dir, + key_inputs.preprocessor_defines, + ), false, ) } @@ -897,10 +916,12 @@ mod tests { }]; let framework_root = tmp.path().join("framework"); + let defines = HashMap::new(); let key_inputs = CacheKeyInputs { toolchain_triple: "test-arm-none-eabi", framework_install_path: &framework_root, framework_version: "0.0.0-test", + preprocessor_defines: &defines, }; let kv = FileKvStore::open(tmp.path().join("kv")).unwrap(); diff --git a/crates/fbuild-cli/src/lib_select.rs b/crates/fbuild-cli/src/lib_select.rs index ac15d3b8..07b339eb 100644 --- a/crates/fbuild-cli/src/lib_select.rs +++ b/crates/fbuild-cli/src/lib_select.rs @@ -15,12 +15,13 @@ //! missing, it prints a clear "framework not installed; run `fbuild build` //! once first" message and exits 2. Downloading is the daemon's job. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use fbuild_config::PlatformIOConfig; use fbuild_core::Platform; use fbuild_core::path::normalize_for_key; -use fbuild_library_select::{Selection, resolve}; +use fbuild_library_select::{Selection, resolve_active}; use fbuild_packages::Framework; use fbuild_packages::library::FrameworkLibrary; use fbuild_packages::library::framework_library::discover_framework_libraries; @@ -107,7 +108,11 @@ pub fn run(project_dir: &Path, env: Option<&str>, explain: bool, json: bool) -> let seeds = collect_project_seeds(&scan_roots); let search_paths = project_search_paths(&scan_roots); - let selection = resolve(&seeds, &search_paths, &libraries); + // The diagnostic must match build-time LDF behavior: optional headers in + // inactive branches are not library dependencies. Sketch-local defines + // are collected by `resolve_active`; board/compiler defines are supplied + // by platform orchestrators during an actual build. + let selection = resolve_active(&seeds, &search_paths, &libraries, &HashMap::new()); if json { emit_json( diff --git a/crates/fbuild-header-scan/src/lib.rs b/crates/fbuild-header-scan/src/lib.rs index 70e83974..38d5aca2 100644 --- a/crates/fbuild-header-scan/src/lib.rs +++ b/crates/fbuild-header-scan/src/lib.rs @@ -9,8 +9,10 @@ mod scanner; mod walker; -pub use scanner::{IncludeKind, IncludeRef, Span, scan}; -pub use walker::{WalkResult, WalkState, walk, walk_with_state}; +pub use scanner::{IncludeKind, IncludeRef, Span, active_defines, scan, scan_active}; +pub use walker::{ + WalkResult, WalkState, walk, walk_active, walk_with_state, walk_with_state_active, +}; /// Bumped whenever the scanner output shape changes. Mixed into cache keys so a /// scanner change invalidates memoized library-selection results. diff --git a/crates/fbuild-header-scan/src/scanner.rs b/crates/fbuild-header-scan/src/scanner.rs index 201a12de..2c42cb68 100644 --- a/crates/fbuild-header-scan/src/scanner.rs +++ b/crates/fbuild-header-scan/src/scanner.rs @@ -3,9 +3,13 @@ //! Tokenizes source byte-by-byte while tracking whether we are inside a line //! comment, block comment, string literal, raw string literal, or character //! literal. `#include` directives are recognized only in normal code state. +//! [`scan`] preserves the legacy behavior and scans every conditional branch; +//! [`scan_active`] evaluates active branches for LDF selection. //! Both branches of `#if` / `#ifdef` are scanned (we do not evaluate //! preprocessor conditionals — false positives are acceptable, false negatives -//! are not). +//! are not) when using `scan`. + +use std::collections::HashMap; /// Whether an include used `<...>` (system / search-path) or `"..."` (quoted / /// same-directory-first). @@ -184,6 +188,257 @@ pub fn scan(src: &str) -> Vec { out } +/// Extract includes reachable through active preprocessor branches. +/// +/// `defines` represents the compiler command line. Macros introduced by an +/// active `#define` in the same file apply to subsequent lines, matching the +/// part of preprocessing relevant to library discovery. +pub fn scan_active(src: &str, defines: &HashMap) -> Vec { + let mut macros = defines.clone(); + scan(&active_source(src, &mut macros)) +} + +/// Return `defines` plus macros declared in active branches of `src`. +/// +/// The LDF uses this for sketch translation units so a sketch-local feature +/// define remains visible when its included headers are scanned. +pub fn active_defines(src: &str, defines: &HashMap) -> HashMap { + let mut macros = defines.clone(); + let _ = active_source(src, &mut macros); + macros +} + +#[derive(Clone, Copy)] +struct Conditional { + parent_active: bool, + branch_taken: bool, +} + +fn active_source(src: &str, macros: &mut HashMap) -> String { + let mut stack = Vec::new(); + let mut active = true; + let mut output = String::with_capacity(src.len()); + for line in src.split_inclusive('\n') { + let directive = line.trim_start().strip_prefix('#').map(str::trim_start); + let mut keep = active; + if let Some(directive) = directive { + let (name, rest) = split_directive(directive); + match name { + "if" => { + let current = active && eval_condition(rest, macros); + stack.push(Conditional { + parent_active: active, + branch_taken: current, + }); + active = current; + keep = false; + } + "ifdef" => { + let current = active && macros.contains_key(first_token(rest)); + stack.push(Conditional { + parent_active: active, + branch_taken: current, + }); + active = current; + keep = false; + } + "ifndef" => { + let current = active && !macros.contains_key(first_token(rest)); + stack.push(Conditional { + parent_active: active, + branch_taken: current, + }); + active = current; + keep = false; + } + "elif" => { + if let Some(current) = stack.last_mut() { + active = current.parent_active + && !current.branch_taken + && eval_condition(rest, macros); + current.branch_taken |= active; + } + keep = false; + } + "else" => { + if let Some(current) = stack.last_mut() { + active = current.parent_active && !current.branch_taken; + current.branch_taken = true; + } + keep = false; + } + "endif" => { + if let Some(current) = stack.pop() { + active = current.parent_active; + } + keep = false; + } + "define" if active => { + let (name, value) = split_directive(rest); + if !name.is_empty() && !name.contains('(') { + macros.insert(name.to_string(), first_token(value).to_string()); + } + keep = false; + } + "undef" if active => { + macros.remove(first_token(rest)); + keep = false; + } + _ => {} + } + } + if keep { + output.push_str(line); + } else if line.ends_with('\n') { + output.push('\n'); + } + } + output +} + +fn split_directive(input: &str) -> (&str, &str) { + let trimmed = input.trim_start(); + let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); + (&trimmed[..end], trimmed[end..].trim_start()) +} + +fn first_token(input: &str) -> &str { + input + .trim_start() + .split(|c: char| c.is_whitespace() || matches!(c, '/' | '*')) + .next() + .unwrap_or("") +} + +fn eval_condition(input: &str, macros: &HashMap) -> bool { + let mut parser = ConditionParser { + input: input.as_bytes(), + index: 0, + macros, + }; + parser.parse_or() != 0 +} + +struct ConditionParser<'a> { + input: &'a [u8], + index: usize, + macros: &'a HashMap, +} + +impl<'a> ConditionParser<'a> { + fn parse_or(&mut self) -> i64 { + let mut value = self.parse_and(); + while self.consume(b"||") { + let rhs = self.parse_and(); + value = i64::from(value != 0 || rhs != 0); + } + value + } + + fn parse_and(&mut self) -> i64 { + let mut value = self.parse_equality(); + while self.consume(b"&&") { + let rhs = self.parse_equality(); + value = i64::from(value != 0 && rhs != 0); + } + value + } + + fn parse_equality(&mut self) -> i64 { + let mut value = self.parse_comparison(); + loop { + if self.consume(b"==") { + value = i64::from(value == self.parse_comparison()); + } else if self.consume(b"!=") { + value = i64::from(value != self.parse_comparison()); + } else { + return value; + } + } + } + + fn parse_comparison(&mut self) -> i64 { + let mut value = self.parse_unary(); + loop { + if self.consume(b">=") { + value = i64::from(value >= self.parse_unary()); + } else if self.consume(b"<=") { + value = i64::from(value <= self.parse_unary()); + } else if self.consume(b">") { + value = i64::from(value > self.parse_unary()); + } else if self.consume(b"<") { + value = i64::from(value < self.parse_unary()); + } else { + return value; + } + } + } + + fn parse_unary(&mut self) -> i64 { + if self.consume(b"!") { + return i64::from(self.parse_unary() == 0); + } + if self.consume(b"(") { + let value = self.parse_or(); + self.consume(b")"); + return value; + } + let token = self.token(); + if token == "defined" { + self.consume(b"("); + let name = self.token(); + self.consume(b")"); + return i64::from(self.macros.contains_key(name)); + } + if let Some(value) = parse_number(token) { + return value; + } + self.macros + .get(token) + .and_then(|value| parse_number(value)) + .unwrap_or(0) + } + + fn consume(&mut self, expected: &[u8]) -> bool { + self.skip_ws(); + if self.input[self.index..].starts_with(expected) { + self.index += expected.len(); + true + } else { + false + } + } + + fn token(&mut self) -> &'a str { + self.skip_ws(); + let start = self.index; + while self.index < self.input.len() + && (self.input[self.index].is_ascii_alphanumeric() || self.input[self.index] == b'_') + { + self.index += 1; + } + std::str::from_utf8(&self.input[start..self.index]).unwrap_or("") + } + + fn skip_ws(&mut self) { + while self.index < self.input.len() && self.input[self.index].is_ascii_whitespace() { + self.index += 1; + } + } +} + +fn parse_number(value: &str) -> Option { + let value = value.trim_end_matches(['u', 'U', 'l', 'L']); + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + i64::from_str_radix(hex, 16).ok() + } else { + value.parse().ok() + } +} + fn is_horizontal_ws(b: u8) -> bool { b == b' ' || b == b'\t' || b == b'\r' } @@ -593,4 +848,26 @@ mod tests { // must be picked up. assert_eq!(refs.len(), 1); } + + #[test] + fn active_scan_ignores_disabled_branch() { + let refs = scan_active( + "#if 0\n#include \n#else\n#include \n#endif\n", + &HashMap::new(), + ); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].path, "SPI.h"); + } + + #[test] + fn active_scan_uses_compiler_and_local_defines() { + let mut defines = HashMap::new(); + defines.insert("ARDUINO".to_string(), "10819".to_string()); + let refs = scan_active( + "#if defined(ARDUINO) && ARDUINO >= 100\n#define USE_SPI 1\n#endif\n#ifdef USE_SPI\n#include \n#endif\n", + &defines, + ); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].path, "SPI.h"); + } } diff --git a/crates/fbuild-header-scan/src/walker.rs b/crates/fbuild-header-scan/src/walker.rs index 8a4c8fe9..4fab519d 100644 --- a/crates/fbuild-header-scan/src/walker.rs +++ b/crates/fbuild-header-scan/src/walker.rs @@ -21,7 +21,7 @@ use std::path::{Path, PathBuf}; use rayon::prelude::*; -use crate::scanner::{IncludeKind, IncludeRef, scan}; +use crate::scanner::{IncludeKind, IncludeRef, scan, scan_active}; /// Result of a walk. `reached` and `unresolved` are sorted for deterministic /// cache keys. @@ -87,6 +87,19 @@ pub fn walk(seeds: &[PathBuf], search_paths: &[PathBuf]) -> WalkResult { walk_with_state(seeds, search_paths, &mut state) } +/// Walk the include graph using only active preprocessor branches. +/// +/// `defines` must be the build's compiler defines. This is the LDF entry +/// point: headers behind a disabled branch do not become library dependencies. +pub fn walk_active( + seeds: &[PathBuf], + search_paths: &[PathBuf], + defines: &HashMap, +) -> WalkResult { + let mut state = WalkState::new(); + walk_with_state_active(seeds, search_paths, defines, &mut state) +} + /// Walk the include graph using a caller-owned [`WalkState`] so the scan cache /// and visited set persist across calls. /// @@ -107,6 +120,28 @@ pub fn walk_with_state( search_paths: &[PathBuf], state: &mut WalkState, ) -> WalkResult { + walk_with_state_scanner(seeds, search_paths, state, &scan) +} + +/// Active-branch counterpart to [`walk_with_state`]. +pub fn walk_with_state_active( + seeds: &[PathBuf], + search_paths: &[PathBuf], + defines: &HashMap, + state: &mut WalkState, +) -> WalkResult { + walk_with_state_scanner(seeds, search_paths, state, &|src| scan_active(src, defines)) +} + +fn walk_with_state_scanner( + seeds: &[PathBuf], + search_paths: &[PathBuf], + state: &mut WalkState, + scanner: &F, +) -> WalkResult +where + F: Fn(&str) -> Vec + Sync, +{ tracing::debug!( seeds = seeds.len(), search_paths = search_paths.len(), @@ -137,7 +172,7 @@ pub fn walk_with_state( .par_iter() .filter_map(|p| { let text = std::fs::read_to_string(p).ok()?; - Some((p.clone(), scan(&text))) + Some((p.clone(), scanner(&text))) }) .collect(); diff --git a/crates/fbuild-library-select/benches/resolve_warm.rs b/crates/fbuild-library-select/benches/resolve_warm.rs index f6f929b8..1142545a 100644 --- a/crates/fbuild-library-select/benches/resolve_warm.rs +++ b/crates/fbuild-library-select/benches/resolve_warm.rs @@ -22,6 +22,7 @@ //! //! Follows up #215 (mini bench) and #205 Phase 7 (perf budgets). +use std::collections::HashMap; use std::path::PathBuf; use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; @@ -70,10 +71,12 @@ fn bench_resolve_warm(c: &mut Criterion) { .expect("resolve_warm: FileKvStore::open failed"); let framework_root = mf.framework_root().to_path_buf(); + let defines = HashMap::new(); let inputs = CacheKeyInputs { toolchain_triple: "avr-unknown-none", framework_install_path: &framework_root, framework_version: "1.59.0", + preprocessor_defines: &defines, }; // Prime the cache so the timed loop measures the hit path only. diff --git a/crates/fbuild-library-select/src/cache.rs b/crates/fbuild-library-select/src/cache.rs index 2a01cfb2..d422a12e 100644 --- a/crates/fbuild-library-select/src/cache.rs +++ b/crates/fbuild-library-select/src/cache.rs @@ -15,6 +15,7 @@ //! - toolchain_triple, //! - framework_install_path, //! - framework_version, +//! - sorted compiler preprocessor defines, //! - [`SCANNER_VERSION`], //! - [`LDF_MODE_VERSION`]. //! @@ -24,20 +25,21 @@ //! and intentional — partial migration of a malformed cache is worse than //! a one-time recompute. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use fbuild_packages::library::FrameworkLibrary; use prost::Message; -use crate::{Selection, canon, resolve}; +use crate::{Selection, canon, resolve_active}; /// Bump when the scanner's lexical grammar changes in a way that could change /// which `#include` directives it emits for the same source. -pub const SCANNER_VERSION: u32 = 1; +pub const SCANNER_VERSION: u32 = 2; /// Bump when the resolver's 2-pass LDF semantics change (seed expansion, /// attribution, convergence rule, etc.). -pub const LDF_MODE_VERSION: u32 = 2; +pub const LDF_MODE_VERSION: u32 = 3; /// Namespace for the library-selection file cache. pub const NAMESPACE: &str = "library-selection"; @@ -184,6 +186,9 @@ pub struct CacheKeyInputs<'a> { /// version file the framework carries — `package.json`, `platform.txt`, /// or a `framework-name@version` line). pub framework_version: &'a str, + /// Defines supplied to the compiler. They select the active include graph + /// and therefore must be part of the cache identity. + pub preprocessor_defines: &'a HashMap, } /// Result of [`resolve_cached`]. `from_cache` distinguishes hit from miss so @@ -227,6 +232,16 @@ pub fn cache_key( h.update(inputs.framework_version.as_bytes()); h.update(b"\n"); + let mut defines: Vec<(&String, &String)> = inputs.preprocessor_defines.iter().collect(); + defines.sort_unstable_by(|left, right| left.0.cmp(right.0)); + h.update(b"defines:"); + for (name, value) in defines { + h.update(name.as_bytes()); + h.update(b"="); + h.update(value.as_bytes()); + h.update(b"\n"); + } + // Seeds: sorted by path, each contributes (canonical_path, content_hash). let mut seed_pairs: Vec<(String, [u8; 32])> = seeds .iter() @@ -333,7 +348,7 @@ pub fn resolve_cached( } } - let selection = resolve(seeds, search_paths, libraries); + let selection = resolve_active(seeds, search_paths, libraries, inputs.preprocessor_defines); // serde's `PathBuf` Serialize impl errors when a path component is not // valid UTF-8 (legal on Unix, possible on Windows via canonicalize edge // cases). Treat that as a cache write miss — degraded performance is @@ -362,8 +377,11 @@ pub fn resolve_cached( #[cfg(test)] mod tests { use super::*; + use std::sync::LazyLock; use tempfile::TempDir; + static EMPTY_DEFINES: LazyLock> = LazyLock::new(HashMap::new); + fn write(path: &Path, contents: &str) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); @@ -388,6 +406,7 @@ mod tests { toolchain_triple: "avr-unknown-none", framework_install_path: framework_root, framework_version: "1.59.0", + preprocessor_defines: &EMPTY_DEFINES, } } @@ -452,11 +471,13 @@ mod tests { toolchain_triple: "avr-unknown-none", framework_install_path: tmp.path(), framework_version: "1.59.0", + preprocessor_defines: &EMPTY_DEFINES, }; let b = CacheKeyInputs { toolchain_triple: "xtensa-esp32-elf", framework_install_path: tmp.path(), framework_version: "1.59.0", + preprocessor_defines: &EMPTY_DEFINES, }; assert_ne!( cache_key(&seeds, &search_paths, &libs, &a).as_bytes(), @@ -472,6 +493,25 @@ mod tests { toolchain_triple: a.toolchain_triple, framework_install_path: a.framework_install_path, framework_version: "1.60.0", + preprocessor_defines: a.preprocessor_defines, + }; + assert_ne!( + cache_key(&seeds, &search_paths, &libs, &a).as_bytes(), + cache_key(&seeds, &search_paths, &libs, &b).as_bytes() + ); + } + + #[test] + fn c04b_define_change_invalidates_key() { + let (tmp, seeds, search_paths, libs) = build_simple_project(); + let a = fixture_inputs(tmp.path()); + let mut defines = HashMap::new(); + defines.insert("USE_AUDIO".to_string(), "1".to_string()); + let b = CacheKeyInputs { + toolchain_triple: a.toolchain_triple, + framework_install_path: a.framework_install_path, + framework_version: a.framework_version, + preprocessor_defines: &defines, }; assert_ne!( cache_key(&seeds, &search_paths, &libs, &a).as_bytes(), diff --git a/crates/fbuild-library-select/src/lib.rs b/crates/fbuild-library-select/src/lib.rs index 7cfc795b..4042bfeb 100644 --- a/crates/fbuild-library-select/src/lib.rs +++ b/crates/fbuild-library-select/src/lib.rs @@ -17,10 +17,10 @@ //! catch anything the header-only pass missed. Libraries newly reached in //! pass 2 are also marked dependent. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; -use fbuild_header_scan::{WalkState, walk_with_state}; +use fbuild_header_scan::{WalkState, active_defines, walk_with_state, walk_with_state_active}; use fbuild_packages::library::FrameworkLibrary; use serde::{Deserialize, Serialize}; @@ -74,6 +74,19 @@ pub fn resolve( resolve_with_stats(seeds, project_search_paths, libraries).0 } +/// Resolve framework libraries using only includes active for `defines`. +/// +/// Build orchestrators must use this entry point so optional framework headers +/// in disabled `#if` branches cannot add object files to the link set. +pub fn resolve_active( + seeds: &[PathBuf], + project_search_paths: &[PathBuf], + libraries: &[FrameworkLibrary], + defines: &HashMap, +) -> Selection { + resolve_with_stats_active(seeds, project_search_paths, libraries, defines).0 +} + /// Same contract as [`resolve`] but also returns [`ResolveStats`] so callers /// can observe the number of physical file reads and LDF passes performed. /// @@ -86,6 +99,46 @@ pub fn resolve_with_stats( seeds: &[PathBuf], project_search_paths: &[PathBuf], libraries: &[FrameworkLibrary], +) -> (Selection, ResolveStats) { + resolve_with_stats_impl(seeds, project_search_paths, libraries, None) +} + +/// Active-branch counterpart to [`resolve_with_stats`]. +pub fn resolve_with_stats_active( + seeds: &[PathBuf], + project_search_paths: &[PathBuf], + libraries: &[FrameworkLibrary], + defines: &HashMap, +) -> (Selection, ResolveStats) { + let effective_defines = seed_defines(seeds, defines); + resolve_with_stats_impl( + seeds, + project_search_paths, + libraries, + Some(&effective_defines), + ) +} + +fn seed_defines( + seeds: &[PathBuf], + compiler_defines: &HashMap, +) -> HashMap { + let mut defines = compiler_defines.clone(); + let mut ordered_seeds = seeds.to_vec(); + ordered_seeds.sort(); + for seed in ordered_seeds { + if let Ok(source) = std::fs::read_to_string(seed) { + defines = active_defines(&source, &defines); + } + } + defines +} + +fn resolve_with_stats_impl( + seeds: &[PathBuf], + project_search_paths: &[PathBuf], + libraries: &[FrameworkLibrary], + defines: Option<&HashMap>, ) -> (Selection, ResolveStats) { let mut selected: BTreeSet = BTreeSet::new(); let mut all_included: BTreeSet = BTreeSet::new(); @@ -117,7 +170,10 @@ pub fn resolve_with_stats( let _span = tracing::info_span!("ldf_pass", pass = 1u32).entered(); pass_count += 1; tracing::info!(pass = 1u32, "ldf_pass"); - let res = walk_with_state(seeds, &full_search_paths, &mut state); + let res = match defines { + Some(defines) => walk_with_state_active(seeds, &full_search_paths, defines, &mut state), + None => walk_with_state(seeds, &full_search_paths, &mut state), + }; for p in &res.reached { all_included.insert(p.clone()); } @@ -149,7 +205,12 @@ pub fn resolve_with_stats( recon_seeds.push(src.clone()); } } - let res = walk_with_state(&recon_seeds, &full_search_paths, &mut state); + let res = match defines { + Some(defines) => { + walk_with_state_active(&recon_seeds, &full_search_paths, defines, &mut state) + } + None => walk_with_state(&recon_seeds, &full_search_paths, &mut state), + }; for p in &res.reached { all_included.insert(p.clone()); } @@ -279,6 +340,36 @@ mod tests { assert!(sel.source_files.contains(&canon(&spi_cpp)) || sel.source_files.contains(&spi_cpp)); } + #[test] + fn active_resolution_skips_library_in_disabled_branch() { + let tmp = tempdir(); + let project_src = tmp.path().join("project").join("src"); + write( + &project_src.join("main.cpp"), + "#define USE_SPI 1\n#include \n", + ); + write( + &project_src.join("FastLED.h"), + "#if defined(USE_AUDIO)\n#include \n#elif USE_SPI\n#include \n#endif\n", + ); + + let mut audio = lib(tmp.path(), "Audio"); + write(&audio.include_dirs[0].join("Audio.h"), ""); + let audio_cpp = audio.include_dirs[0].join("Audio.cpp"); + write(&audio_cpp, ""); + audio.source_files.push(audio_cpp); + + let mut spi = lib(tmp.path(), "SPI"); + write(&spi.include_dirs[0].join("SPI.h"), ""); + let spi_cpp = spi.include_dirs[0].join("SPI.cpp"); + write(&spi_cpp, ""); + spi.source_files.push(spi_cpp); + + let seeds = vec![project_src.join("main.cpp")]; + let selection = resolve_active(&seeds, &[project_src], &[audio, spi], &HashMap::new()); + assert_eq!(selection.required_libraries, vec!["SPI".to_string()]); + } + #[test] fn r02_transitive_library_selection() { let tmp = tempdir(); From b087e0a32ff8f7c2dccaec12cebf536e8bab64cf Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 08:20:41 -0700 Subject: [PATCH 04/10] ci: rerun LDF selection checks From dd30c6425d76220cac7ab54b5a7b8e51bc01eef4 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 08:35:11 -0700 Subject: [PATCH 05/10] fix(bench): include LDF defines in cache inputs --- bench/fastled-examples/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bench/fastled-examples/src/main.rs b/bench/fastled-examples/src/main.rs index 81c0470c..215673d6 100644 --- a/bench/fastled-examples/src/main.rs +++ b/bench/fastled-examples/src/main.rs @@ -46,6 +46,7 @@ //! //! Refs: #205 Phase 7 (AC#5), #218. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -211,10 +212,12 @@ fn measure_example( let kv_dir = tempfile::tempdir_in(fbuild_paths::temp_subdir("fastled-examples-bench"))?; let kv = FileKvStore::open(kv_dir.path().join("kv"))?; + let preprocessor_defines = HashMap::new(); let inputs = CacheKeyInputs { toolchain_triple: "teensy-arm-none-eabi", framework_install_path: framework_root, framework_version: "bench-fastled-examples-v1", + preprocessor_defines: &preprocessor_defines, }; let (cold, cold_ms) = timed(|| resolve_cached(&seeds, &search_paths, libraries, &inputs, &kv))?; From a42c27192731f45d72d3d5c3ed38eaf95232dbd4 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 08:40:57 -0700 Subject: [PATCH 06/10] style: format LDF resolver --- crates/fbuild-build-engine/src/framework_libs.rs | 9 ++++----- crates/fbuild-cli/src/lib_select.rs | 5 +---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/fbuild-build-engine/src/framework_libs.rs b/crates/fbuild-build-engine/src/framework_libs.rs index 622c77ca..e3755146 100644 --- a/crates/fbuild-build-engine/src/framework_libs.rs +++ b/crates/fbuild-build-engine/src/framework_libs.rs @@ -17,7 +17,9 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use fbuild_library_select::cache::{CacheKeyInputs, FileKvStore, resolve_cached}; -use fbuild_library_select::{resolve as resolve_library_selection, resolve_active as resolve_active_library_selection}; +use fbuild_library_select::{ + resolve as resolve_library_selection, resolve_active as resolve_active_library_selection, +}; use fbuild_packages::library::FrameworkLibrary; use walkdir::{DirEntry, WalkDir}; @@ -434,10 +436,7 @@ fn is_translation_unit(path: &Path) -> bool { .and_then(|ext| ext.to_str()) .unwrap_or_default() .to_lowercase(); - matches!( - ext.as_str(), - "c" | "cpp" | "cc" | "cxx" | "s" | "ino" - ) + matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") } #[cfg(test)] diff --git a/crates/fbuild-cli/src/lib_select.rs b/crates/fbuild-cli/src/lib_select.rs index 07b339eb..87526096 100644 --- a/crates/fbuild-cli/src/lib_select.rs +++ b/crates/fbuild-cli/src/lib_select.rs @@ -359,10 +359,7 @@ fn is_translation_unit(path: &Path) -> bool { .and_then(|e| e.to_str()) .unwrap_or_default() .to_lowercase(); - matches!( - ext.as_str(), - "c" | "cpp" | "cc" | "cxx" | "s" | "ino" - ) + matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") } /// For each selected library, find one reached file under its include dirs to From 8abc4210dcb3d596b9833402ed28263d633d0a78 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 08:45:29 -0700 Subject: [PATCH 07/10] style: align LDF resolver with pinned rustfmt --- crates/fbuild-build-engine/src/framework_libs.rs | 2 +- crates/fbuild-cli/src/lib_select.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fbuild-build-engine/src/framework_libs.rs b/crates/fbuild-build-engine/src/framework_libs.rs index e3755146..24222350 100644 --- a/crates/fbuild-build-engine/src/framework_libs.rs +++ b/crates/fbuild-build-engine/src/framework_libs.rs @@ -436,7 +436,7 @@ fn is_translation_unit(path: &Path) -> bool { .and_then(|ext| ext.to_str()) .unwrap_or_default() .to_lowercase(); - matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") + matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") } #[cfg(test)] diff --git a/crates/fbuild-cli/src/lib_select.rs b/crates/fbuild-cli/src/lib_select.rs index 87526096..44a93ebc 100644 --- a/crates/fbuild-cli/src/lib_select.rs +++ b/crates/fbuild-cli/src/lib_select.rs @@ -359,7 +359,7 @@ fn is_translation_unit(path: &Path) -> bool { .and_then(|e| e.to_str()) .unwrap_or_default() .to_lowercase(); - matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") + matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s" | "ino") } /// For each selected library, find one reached file under its include dirs to From 9779e86efa2cdeca2500d24215047c16ba2b262f Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 09:01:54 -0700 Subject: [PATCH 08/10] fix(daemon): await USB profile initialization --- crates/fbuild-daemon/src/main.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index d5453c6b..302917a5 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -99,10 +99,12 @@ async fn main() { tracing::info!("listening on {}", addr); // Populate the FastLED/boards USB caches used by device discovery and - // deployment. Best-effort display-name failure degrades to deterministic - // unknown labels; identity-dependent behavior fails closed. Runs on a - // blocking thread so a slow network doesn't stall daemon bootstrap. - tokio::task::spawn_blocking(populate_usb_overlay_best_effort); + // deployment before starting any compile work. The typed profile provides + // USB_VID/USB_PID for cores such as Adafruit SAMD, so serving a build + // before this blocking initialization finishes makes the result depend on + // a startup race. Display-name lookup remains best-effort; unavailable + // identity-dependent behavior still fails closed. + let _ = tokio::task::spawn_blocking(populate_usb_overlay_best_effort).await; // FastLED/fbuild#800 (Phase 4 stage 2 of #789): start the embedded // zccache service inside this tokio runtime and install the global From e4a9bf8fc13cdaa49091833c04b968dd065dc6fc Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 09:27:07 -0700 Subject: [PATCH 09/10] fix(config): canonicalize board profile lookup --- crates/fbuild-config/src/board/db.rs | 9 +++++++++ crates/fbuild-config/src/board/methods.rs | 4 +++- crates/fbuild-config/src/board/tests_usb_vid.rs | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/fbuild-config/src/board/db.rs b/crates/fbuild-config/src/board/db.rs index aa87d90c..7c215b69 100644 --- a/crates/fbuild-config/src/board/db.rs +++ b/crates/fbuild-config/src/board/db.rs @@ -75,6 +75,15 @@ fn resolve_board_alias(board_id: &str) -> &str { } } +/// Return the canonical bundled board ID used by FastLED/boards profiles. +/// +/// Build configuration accepts a small number of PlatformIO compatibility +/// aliases. Registry-backed metadata must use the corresponding canonical ID, +/// or a valid board profile becomes invisible after the local alias resolves. +pub(super) fn registry_board_id(board_id: &str) -> &str { + resolve_board_alias(board_id) +} + /// Extract debug tools from a board JSON entry's `debug.tools` section. pub(super) fn get_board_debug_tools(board_id: &str) -> Option> { let db = get_board_db(); diff --git a/crates/fbuild-config/src/board/methods.rs b/crates/fbuild-config/src/board/methods.rs index d9d34b0e..0497a41c 100644 --- a/crates/fbuild-config/src/board/methods.rs +++ b/crates/fbuild-config/src/board/methods.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use super::db::registry_board_id; use super::types::{BoardConfig, DebugToolMeta, EMULATOR_TOOL_NAMES, Esp32QemuPsramConfig}; impl BoardConfig { @@ -57,7 +58,8 @@ impl BoardConfig { } fn registry_compile_identity(&self) -> Option<(u16, u16)> { - fbuild_core::usb::profiles::board_profile(&self.board_id)?.primary_compile_identity + fbuild_core::usb::profiles::board_profile(registry_board_id(&self.board_id))? + .primary_compile_identity } pub(super) fn formatted_registry_compile_identity( diff --git a/crates/fbuild-config/src/board/tests_usb_vid.rs b/crates/fbuild-config/src/board/tests_usb_vid.rs index 4ecf76f1..7e1fe50a 100644 --- a/crates/fbuild-config/src/board/tests_usb_vid.rs +++ b/crates/fbuild-config/src/board/tests_usb_vid.rs @@ -58,6 +58,14 @@ fn test_registry_compile_identity_define_format() { assert_eq!(BoardConfig::formatted_registry_compile_identity(None), None); } +#[test] +fn test_registry_lookup_uses_canonical_bundled_board_id() { + assert_eq!( + super::db::registry_board_id("adafruit_grand_central_m4"), + "adafruit_grandcentral_m4" + ); +} + #[test] #[ignore = "live FastLED/boards publication smoke test"] fn live_registry_identity_drives_pico_compile_defines() { From 6e1e63f0a6750de5861cecf48948ca3182a7d6c6 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Jul 2026 09:30:30 -0700 Subject: [PATCH 10/10] fix(ldf): align stm32 resolver defines --- .../src/stm32/orchestrator/mod.rs | 65 ++++++++----------- crates/fbuild-library-select/src/lib.rs | 9 +-- docs/architecture/library-selection.md | 4 +- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs b/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs index 64fa25b2..ce32e9b8 100644 --- a/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs +++ b/crates/fbuild-build-arm/src/stm32/orchestrator/mod.rs @@ -146,10 +146,33 @@ impl BuildOrchestrator for Stm32Orchestrator { // STM32duino only exposes bundled libraries via this framework-level // discovery (PlatformIO's LDF does the same for `framework = arduino`). let framework_libs = framework.get_framework_libraries(); - let ldf_mcu_config = + let mut mcu_config = super::mcu_config::get_stm32_config_for_mcu(&ctx.board.mcu.to_lowercase())?; - let mut ldf_defines = ctx.board.get_defines(); - ldf_defines.extend(ldf_mcu_config.defines_map()); + // Extract MCU family from variant path (e.g. "STM32F1xx" from + // "STM32F1xx/F103C8T_..."). LDF must see precisely the same defines + // as the compiler so it does not discard a conditional dependency. + let family = resolved_variant.split('/').next().unwrap_or("STM32F1xx"); + let mut defines = ctx.board.get_defines(); + defines.extend(mcu_config.defines_map()); + if let Some(board_define) = framework_props + .as_ref() + .and_then(|props| props.get("board")) + { + defines.insert(format!("ARDUINO_{board_define}"), "1".to_string()); + } + defines.insert(family.to_string(), "1".to_string()); + let generic_board = stm32_generic_board_define(&ctx.board.mcu); + defines.insert(format!("ARDUINO_{generic_board}"), "1".to_string()); + defines.insert("USE_HAL_DRIVER".to_string(), "1".to_string()); + defines.insert("USE_FULL_LL_DRIVER".to_string(), "1".to_string()); + defines.insert( + "VARIANT_H".to_string(), + format!("\\\"{}\\\"", selected_variant.header), + ); + // UART HAL is disabled by default in stm32yyxx_hal_conf.h; enable it + // so WSerial can create Serial. USB PCD is needed by USB variants. + defines.insert("HAL_UART_MODULE_ENABLED".to_string(), "1".to_string()); + defines.insert("HAL_PCD_MODULE_ENABLED".to_string(), "1".to_string()); // WHY: STM32duino targets every Cortex-M family from M0 (F0xx) up // through M7 (H7xx) but the toolchain triple is constant // (`arm-none-eabi`). The cache key already includes @@ -163,7 +186,7 @@ impl BuildOrchestrator for Stm32Orchestrator { toolchain_triple: "stm32-arm-none-eabi", framework_install_path: &framework_info.install_path, framework_version: &framework_info.version, - preprocessor_defines: &ldf_defines, + preprocessor_defines: &defines, }; resolve_framework_library_sources_cached( &framework_libs, @@ -177,7 +200,7 @@ impl BuildOrchestrator for Stm32Orchestrator { &framework_libs, ¶ms.project_dir, &ctx.src_dir, - &ldf_defines, + &defines, ), }; if !framework_library_sources.is_empty() { @@ -196,11 +219,6 @@ impl BuildOrchestrator for Stm32Orchestrator { ); // 6. Build include dirs + compiler - // Extract MCU family from variant path (e.g. "STM32F1xx" from "STM32F1xx/F103C8T_...") - let family = resolved_variant.split('/').next().unwrap_or("STM32F1xx"); - - let mut mcu_config = - super::mcu_config::get_stm32_config_for_mcu(&ctx.board.mcu.to_lowercase())?; // STM32duino linker scripts reference LD_MAX_SIZE, LD_MAX_DATA_SIZE, and // LD_FLASH_OFFSET as symbols. Provide them via --defsym from board config. let max_flash = ctx.board.max_flash.unwrap_or(65536); @@ -214,33 +232,6 @@ impl BuildOrchestrator for Stm32Orchestrator { mcu_config .linker_flags .push("-Wl,--defsym=LD_FLASH_OFFSET=0".to_string()); - let mut defines = ctx.board.get_defines(); - defines.extend(mcu_config.defines_map()); - if let Some(board_define) = framework_props - .as_ref() - .and_then(|props| props.get("board")) - { - defines.insert(format!("ARDUINO_{board_define}"), "1".to_string()); - } - // STM32duino's stm32_def.h checks for STM32YYxx (e.g. STM32F1xx). The board - // JSON extra_flags may only have STM32F1, so ensure the full family define. - defines.insert(family.to_string(), "1".to_string()); - // STM32duino variant sources are guarded by ARDUINO_GENERIC_ defines. - // Derive from the MCU name: stm32f103c8t6 → ARDUINO_GENERIC_F103C8TX - let generic_board = stm32_generic_board_define(&ctx.board.mcu); - defines.insert(format!("ARDUINO_{generic_board}"), "1".to_string()); - // STM32duino requires these defines for HAL/LL and variant header resolution. - defines.insert("USE_HAL_DRIVER".to_string(), "1".to_string()); - defines.insert("USE_FULL_LL_DRIVER".to_string(), "1".to_string()); - defines.insert( - "VARIANT_H".to_string(), - format!("\\\"{}\\\"", selected_variant.header), - ); - // UART HAL module is disabled by default in stm32yyxx_hal_conf.h — enable it - // so WSerial.h can create the Serial instance. - defines.insert("HAL_UART_MODULE_ENABLED".to_string(), "1".to_string()); - defines.insert("HAL_PCD_MODULE_ENABLED".to_string(), "1".to_string()); - // Build include dirs manually (can't use get_include_paths because // STM32duino core dir is "arduino", not the board JSON's "stm32") let mut include_dirs = vec![core_dir.clone(), variant_dir.clone()]; diff --git a/crates/fbuild-library-select/src/lib.rs b/crates/fbuild-library-select/src/lib.rs index 4042bfeb..e9feb866 100644 --- a/crates/fbuild-library-select/src/lib.rs +++ b/crates/fbuild-library-select/src/lib.rs @@ -1,7 +1,7 @@ //! PlatformIO-LDF-style library resolver. //! -//! Given a set of seed source files (the project's `src/`, `lib/`, `include/` -//! trees), a list of discovered framework libraries, and the project's include +//! Given a set of seed source files (the sketch and project's `src/` tree), a +//! list of discovered framework libraries, and the project's include //! roots, `resolve()` returns the set of framework libraries transitively //! reachable from the seeds plus the compile-set for each selected library. //! @@ -60,8 +60,9 @@ pub struct Selection { /// Resolve the transitive library selection for a project. /// -/// `seeds` are the source files to walk from (sketch, project `src/`, -/// `include/`, `lib/` trees). +/// `seeds` are the source files to walk from (sketch and project `src/`). +/// Local `lib/` and `include/` headers are discovered only through the +/// transitive include graph; they are not independent LDF roots. /// `project_search_paths` are the project's own include roots — consulted for /// `<...>` includes before framework libs. /// `libraries` is the full set of framework libraries discovered under the diff --git a/docs/architecture/library-selection.md b/docs/architecture/library-selection.md index a5e067f1..148ae023 100644 --- a/docs/architecture/library-selection.md +++ b/docs/architecture/library-selection.md @@ -44,8 +44,8 @@ code changes. ## Sequence ```text -project sources framework libraries -(src/, lib/, include/) (e.g. Arduino_Core_STM32/libraries/*) +project translation units framework libraries +(sketch, src/) (e.g. Arduino_Core_STM32/libraries/*) │ │ │ collect_project_seeds │ FrameworkLibrary { name, ▼ │ include_dirs, source_files }