From 6e9a86c56218090cd06b56f1e4189352a9c255e0 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 24 Apr 2026 15:09:20 -0700 Subject: [PATCH] fix(stm32): discover Arduino_Core_STM32 framework libraries (SPI, Wire, ...) The STM32 orchestrator installs the STM32duino core but never walked libraries/*/ to surface bundled libraries to sketches. Any sketch that transitively included (e.g. FastLED's STM32 fastspi path) failed with "fatal error: SPI.h: No such file or directory" despite SPI living right there in the cache. Mirror PR #164's Teensy fix: - Add a shared FrameworkLibrary model + discover_framework_libraries() walker in fbuild-packages, used by both TeensyCores and Stm32Cores. - Extract the #include-scanning / transitive-closure resolver out of the Teensy orchestrator into crate::framework_libs so both platforms share one code path. - Wire Stm32Cores::get_framework_libraries() + get_framework_library_include_dirs() into the STM32 orchestrator after SrcWrapper so the resolver picks up SPI/Wire/EEPROM/... on demand and their include dirs reach the sketch compile command. Fixes #202 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/fbuild-build/src/framework_libs.rs | 417 ++++++++++++++++++ crates/fbuild-build/src/lib.rs | 1 + crates/fbuild-build/src/stm32/orchestrator.rs | 20 + .../fbuild-build/src/teensy/orchestrator.rs | 405 +---------------- .../src/library/framework_library.rs | 194 ++++++++ crates/fbuild-packages/src/library/mod.rs | 4 +- .../fbuild-packages/src/library/stm32_core.rs | 14 + .../src/library/teensy_core.rs | 134 +----- 8 files changed, 661 insertions(+), 528 deletions(-) create mode 100644 crates/fbuild-build/src/framework_libs.rs create mode 100644 crates/fbuild-packages/src/library/framework_library.rs diff --git a/crates/fbuild-build/src/framework_libs.rs b/crates/fbuild-build/src/framework_libs.rs new file mode 100644 index 00000000..b8f813bc --- /dev/null +++ b/crates/fbuild-build/src/framework_libs.rs @@ -0,0 +1,417 @@ +//! Framework-library resolution shared across platform orchestrators. +//! +//! PlatformIO ships Arduino-style frameworks (Teensyduino, STM32duino, ...) +//! with a `libraries/` directory containing bundled libraries like `SPI` and +//! `Wire`. A sketch that does `#include ` must get the library's +//! include dirs on the compiler's search path and its sources linked in. +//! +//! This module walks project sources for `#include` directives, matches them +//! against the library's exported headers, and returns the set of source +//! files that must be compiled. It transitively follows includes inside +//! selected libraries so `A.h -> B.h` pulls in B as well, and it shadows +//! framework libraries with project-local copies of the same name so a user +//! can override a bundled library by vendoring it under `lib//`. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use fbuild_packages::library::FrameworkLibrary; +use walkdir::{DirEntry, WalkDir}; + +/// Resolve framework library source files needed by a project. +pub fn resolve_framework_library_sources( + libraries: &[FrameworkLibrary], + project_dir: &Path, + src_dir: &Path, +) -> Vec { + let roots = framework_include_scan_roots(project_dir, src_dir); + resolve_framework_library_sources_from_libraries(libraries, &roots) +} + +/// Selection algorithm: build a header-to-library map, transitively follow +/// includes from project sources, prefer project-local headers, emit the +/// selected libraries' source files deduped and sorted. +pub fn resolve_framework_library_sources_from_libraries( + libraries: &[FrameworkLibrary], + roots: &[PathBuf], +) -> Vec { + let mut header_to_library = HashMap::new(); + for (idx, library) in libraries.iter().enumerate() { + let mut headers = HashSet::new(); + for include_dir in &library.include_dirs { + collect_header_names(include_dir, &mut headers); + } + for header in headers { + header_to_library.entry(header).or_insert(idx); + } + } + + let mut local_headers = HashSet::new(); + for root in roots { + collect_header_names(root, &mut local_headers); + } + + let mut pending = HashSet::new(); + for root in roots { + collect_included_headers(root, &mut pending); + } + + let mut selected = HashSet::new(); + let mut queue: Vec = pending.iter().cloned().collect(); + while let Some(header) = queue.pop() { + if local_headers.contains(&header) { + continue; + } + let Some(&library_idx) = header_to_library.get(&header) else { + continue; + }; + if !selected.insert(library_idx) { + continue; + } + + let mut transitive_headers = HashSet::new(); + collect_framework_included_headers(&libraries[library_idx].dir, &mut transitive_headers); + for transitive in transitive_headers { + if pending.insert(transitive.clone()) { + queue.push(transitive); + } + } + } + + let mut selected_indices: Vec<_> = selected.into_iter().collect(); + selected_indices.sort_unstable(); + + let mut sources = Vec::new(); + for idx in selected_indices { + tracing::info!( + "selected framework library '{}': {} source files", + libraries[idx].name, + libraries[idx].source_files.len() + ); + sources.extend(libraries[idx].source_files.iter().cloned()); + } + sources.sort(); + sources.dedup(); + sources +} + +/// Project directories to scan for `#include` directives and local headers. +pub fn framework_include_scan_roots(project_dir: &Path, src_dir: &Path) -> Vec { + let mut roots = Vec::new(); + push_existing_unique(&mut roots, src_dir.to_path_buf()); + push_existing_unique(&mut roots, project_dir.join("src")); + push_existing_unique(&mut roots, project_dir.join("include")); + push_existing_unique(&mut roots, project_dir.join("lib")); + roots +} + +fn push_existing_unique(roots: &mut Vec, path: PathBuf) { + if !path.exists() { + return; + } + if !roots.iter().any(|existing| existing == &path) { + roots.push(path); + } +} + +fn collect_header_names(root: &Path, headers: &mut HashSet) { + if !root.exists() { + return; + } + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(should_scan_framework_entry) + .flatten() + { + if !entry.file_type().is_file() || !is_header_file(entry.path()) { + continue; + } + if let Some(name) = entry.path().file_name().and_then(|name| name.to_str()) { + headers.insert(name.to_string()); + } + } +} + +fn collect_included_headers(root: &Path, headers: &mut HashSet) { + collect_included_headers_with_filter(root, headers, should_scan_entry); +} + +fn collect_framework_included_headers(root: &Path, headers: &mut HashSet) { + collect_included_headers_with_filter(root, headers, should_scan_framework_entry); +} + +fn collect_included_headers_with_filter( + root: &Path, + headers: &mut HashSet, + filter: fn(&DirEntry) -> bool, +) { + if !root.exists() { + return; + } + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(filter) + .flatten() + { + if !entry.file_type().is_file() || !is_source_or_header_file(entry.path()) { + continue; + } + let Ok(content) = std::fs::read_to_string(entry.path()) else { + continue; + }; + for line in content.lines() { + if let Some(header) = parse_include_header(line) { + headers.insert(header); + } + } + } +} + +fn should_scan_entry(entry: &DirEntry) -> bool { + let name = entry.file_name().to_string_lossy().to_lowercase(); + !matches!( + name.as_str(), + ".git" + | ".pio" + | ".fbuild" + | ".zap" + | ".build" + | "build" + | "target" + | ".venv" + | "venv" + | "node_modules" + | "__pycache__" + ) +} + +fn should_scan_framework_entry(entry: &DirEntry) -> bool { + if !should_scan_entry(entry) { + return false; + } + let name = entry.file_name().to_string_lossy().to_lowercase(); + !matches!( + name.as_str(), + "examples" | "example" | "extras" | "test" | "tests" | "fontconvert" + ) +} + +fn is_source_or_header_file(path: &Path) -> bool { + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_lowercase(); + matches!( + ext.as_str(), + "c" | "cpp" | "cc" | "cxx" | "s" | "ino" | "h" | "hh" | "hpp" | "hxx" + ) +} + +fn is_header_file(path: &Path) -> bool { + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_lowercase(); + matches!(ext.as_str(), "h" | "hh" | "hpp" | "hxx") +} + +fn parse_include_header(line: &str) -> Option { + let trimmed = line.trim_start(); + let directive = trimmed.strip_prefix('#')?.trim_start(); + let rest = directive.strip_prefix("include")?.trim_start(); + let mut chars = rest.chars(); + let opener = chars.next()?; + let closer = match opener { + '<' => '>', + '"' => '"', + _ => return None, + }; + let remainder = &rest[opener.len_utf8()..]; + let end = remainder.find(closer)?; + let include_path = &remainder[..end]; + Path::new(include_path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_include_extracts_basename() { + assert_eq!( + parse_include_header("#include "), + Some("SPI.h".to_string()) + ); + assert_eq!( + parse_include_header(" # include \"utility/foo.hpp\""), + Some("foo.hpp".to_string()) + ); + assert_eq!(parse_include_header("int x = 1;"), None); + } + + #[test] + fn resolves_libraries_from_project_includes() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_src = tmp.path().join("project").join("src"); + std::fs::create_dir_all(&project_src).unwrap(); + std::fs::write( + project_src.join("main.cpp"), + "#include \n#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 octo_dir = tmp + .path() + .join("framework") + .join("libraries") + .join("OctoWS2811"); + std::fs::create_dir_all(&octo_dir).unwrap(); + std::fs::write(octo_dir.join("OctoWS2811.h"), "").unwrap(); + std::fs::write(octo_dir.join("OctoWS2811.cpp"), "").unwrap(); + std::fs::write(octo_dir.join("OctoWS2811_imxrt.cpp"), "").unwrap(); + + let libraries = vec![ + FrameworkLibrary { + name: "OctoWS2811".to_string(), + dir: octo_dir.clone(), + include_dirs: vec![octo_dir.clone()], + source_files: vec![ + octo_dir.join("OctoWS2811.cpp"), + octo_dir.join("OctoWS2811_imxrt.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 mut sources = resolve_framework_library_sources_from_libraries( + &libraries, + std::slice::from_ref(&project_src), + ); + sources.sort(); + + assert_eq!( + sources, + vec![ + octo_dir.join("OctoWS2811.cpp"), + octo_dir.join("OctoWS2811_imxrt.cpp"), + spi_dir.join("SPI.cpp"), + ] + ); + } + + #[test] + fn follows_transitive_includes() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_src = tmp.path().join("project").join("src"); + std::fs::create_dir_all(&project_src).unwrap(); + std::fs::write(project_src.join("main.cpp"), "#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 wrapper_dir = tmp + .path() + .join("framework") + .join("libraries") + .join("NeedsSpi"); + std::fs::create_dir_all(&wrapper_dir).unwrap(); + std::fs::write(wrapper_dir.join("NeedsSpi.h"), "#include \n").unwrap(); + std::fs::write(wrapper_dir.join("NeedsSpi.cpp"), "").unwrap(); + + let libraries = vec![ + FrameworkLibrary { + name: "NeedsSpi".to_string(), + dir: wrapper_dir.clone(), + include_dirs: vec![wrapper_dir.clone()], + source_files: vec![wrapper_dir.join("NeedsSpi.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 mut sources = resolve_framework_library_sources_from_libraries( + &libraries, + std::slice::from_ref(&project_src), + ); + sources.sort(); + + assert_eq!( + sources, + vec![wrapper_dir.join("NeedsSpi.cpp"), spi_dir.join("SPI.cpp")] + ); + } + + #[test] + fn prefers_local_library_over_framework() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_src = tmp.path().join("project").join("src"); + let project_lib = tmp + .path() + .join("project") + .join("lib") + .join("FastLED") + .join("src"); + std::fs::create_dir_all(&project_src).unwrap(); + std::fs::create_dir_all(&project_lib).unwrap(); + std::fs::write(project_src.join("main.cpp"), "#include \n").unwrap(); + std::fs::write(project_lib.join("FastLED.h"), "#include \n").unwrap(); + std::fs::write(project_lib.join("FastLED.cpp"), "").unwrap(); + + let framework_fastled_dir = tmp + .path() + .join("framework") + .join("libraries") + .join("FastLED"); + std::fs::create_dir_all(&framework_fastled_dir).unwrap(); + std::fs::write(framework_fastled_dir.join("FastLED.h"), "").unwrap(); + std::fs::write(framework_fastled_dir.join("FastLED.cpp"), "").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 libraries = vec![ + FrameworkLibrary { + name: "FastLED".to_string(), + dir: framework_fastled_dir.clone(), + include_dirs: vec![framework_fastled_dir.clone()], + source_files: vec![framework_fastled_dir.join("FastLED.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 roots = vec![project_src, project_lib]; + let sources = resolve_framework_library_sources_from_libraries(&libraries, &roots); + + assert_eq!(sources, vec![spi_dir.join("SPI.cpp")]); + } +} diff --git a/crates/fbuild-build/src/lib.rs b/crates/fbuild-build/src/lib.rs index b161012d..7aaaa1f1 100644 --- a/crates/fbuild-build/src/lib.rs +++ b/crates/fbuild-build/src/lib.rs @@ -14,6 +14,7 @@ pub mod compiler; pub mod esp32; pub mod esp8266; pub mod flag_overlay; +pub mod framework_libs; pub mod generic_arm; pub mod linker; pub mod nrf52; diff --git a/crates/fbuild-build/src/stm32/orchestrator.rs b/crates/fbuild-build/src/stm32/orchestrator.rs index a4d21b4b..0da735c1 100644 --- a/crates/fbuild-build/src/stm32/orchestrator.rs +++ b/crates/fbuild-build/src/stm32/orchestrator.rs @@ -20,6 +20,7 @@ use fbuild_core::{Platform, Result}; use fbuild_packages::{Framework, Toolchain}; use crate::compile_database::TargetArchitecture; +use crate::framework_libs::resolve_framework_library_sources; use crate::generic_arm::{ArmCompiler, ArmLinker}; use crate::pipeline; use crate::source_scanner::SourceCollection; @@ -103,6 +104,22 @@ impl BuildOrchestrator for Stm32Orchestrator { .extend(scanner.scan_core_sources(&srcwrapper_src)); } + // Walk Arduino_Core_STM32's libraries/ (SPI, Wire, EEPROM, ...) and + // pull in any the sketch transitively #includes. Without this, sketches + // that include fail with "No such file or directory" because + // 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 framework_library_sources = + resolve_framework_library_sources(&framework_libs, ¶ms.project_dir, &ctx.src_dir); + if !framework_library_sources.is_empty() { + tracing::info!( + "STM32 framework library sources added: {}", + framework_library_sources.len() + ); + sources.core_sources.extend(framework_library_sources); + } + tracing::info!( "sources: {} sketch, {} core, {} variant", sources.sketch_sources.len(), @@ -173,6 +190,9 @@ impl BuildOrchestrator for Stm32Orchestrator { } include_dirs.push(ctx.src_dir.clone()); pipeline::discover_project_includes(¶ms.project_dir, &mut include_dirs); + // Bundled framework library headers (SPI, Wire, EEPROM, ...) so + // sketches can `#include ` etc. + include_dirs.extend(framework.get_framework_library_include_dirs()); // STM32duino system includes (CMSIS device, HAL drivers, etc.) let system_dir = framework.get_system_dir(); diff --git a/crates/fbuild-build/src/teensy/orchestrator.rs b/crates/fbuild-build/src/teensy/orchestrator.rs index 84f182a8..f3854e8f 100644 --- a/crates/fbuild-build/src/teensy/orchestrator.rs +++ b/crates/fbuild-build/src/teensy/orchestrator.rs @@ -12,14 +12,11 @@ //! 9. Link (with linker script from teensy4/) //! 10. Convert to hex + report size -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Instant; use fbuild_core::{Platform, Result}; -use fbuild_packages::library::TeensyFrameworkLibrary; use serde::Serialize; -use walkdir::{DirEntry, WalkDir}; use crate::build_fingerprint::{ expected_fast_path_artifacts, stable_hash_json, FastPathCheckInputs, FastPathContract, @@ -27,6 +24,7 @@ use crate::build_fingerprint::{ }; use crate::compile_database::TargetArchitecture; use crate::compiler::Compiler as _; +use crate::framework_libs::resolve_framework_library_sources; use crate::pipeline; use crate::{BuildOrchestrator, BuildParams, BuildResult, SourceScanner}; @@ -60,223 +58,6 @@ fn profile_label(profile: fbuild_core::BuildProfile) -> &'static str { } } -fn resolve_teensy_framework_library_sources( - framework: &fbuild_packages::library::TeensyCores, - project_dir: &Path, - src_dir: &Path, -) -> Vec { - let libraries = framework.get_framework_libraries(); - let roots = teensy_include_scan_roots(project_dir, src_dir); - resolve_teensy_framework_library_sources_from_libraries(&libraries, &roots) -} - -fn resolve_teensy_framework_library_sources_from_libraries( - libraries: &[TeensyFrameworkLibrary], - roots: &[PathBuf], -) -> Vec { - let mut header_to_library = HashMap::new(); - for (idx, library) in libraries.iter().enumerate() { - let mut headers = HashSet::new(); - for include_dir in &library.include_dirs { - collect_header_names(include_dir, &mut headers); - } - for header in headers { - header_to_library.entry(header).or_insert(idx); - } - } - - let mut local_headers = HashSet::new(); - for root in roots { - collect_header_names(root, &mut local_headers); - } - - let mut pending = HashSet::new(); - for root in roots { - collect_included_headers(root, &mut pending); - } - - let mut selected = HashSet::new(); - let mut queue: Vec = pending.iter().cloned().collect(); - while let Some(header) = queue.pop() { - if local_headers.contains(&header) { - continue; - } - let Some(&library_idx) = header_to_library.get(&header) else { - continue; - }; - if !selected.insert(library_idx) { - continue; - } - - let mut transitive_headers = HashSet::new(); - collect_framework_included_headers(&libraries[library_idx].dir, &mut transitive_headers); - for transitive in transitive_headers { - if pending.insert(transitive.clone()) { - queue.push(transitive); - } - } - } - - let mut selected_indices: Vec<_> = selected.into_iter().collect(); - selected_indices.sort_unstable(); - - let mut sources = Vec::new(); - for idx in selected_indices { - tracing::info!( - "selected Teensy framework library '{}': {} source files", - libraries[idx].name, - libraries[idx].source_files.len() - ); - sources.extend(libraries[idx].source_files.iter().cloned()); - } - sources.sort(); - sources.dedup(); - sources -} - -fn teensy_include_scan_roots(project_dir: &Path, src_dir: &Path) -> Vec { - let mut roots = Vec::new(); - push_existing_unique(&mut roots, src_dir.to_path_buf()); - push_existing_unique(&mut roots, project_dir.join("src")); - push_existing_unique(&mut roots, project_dir.join("include")); - push_existing_unique(&mut roots, project_dir.join("lib")); - roots -} - -fn push_existing_unique(roots: &mut Vec, path: PathBuf) { - if !path.exists() { - return; - } - if !roots.iter().any(|existing| existing == &path) { - roots.push(path); - } -} - -fn collect_header_names(root: &Path, headers: &mut HashSet) { - if !root.exists() { - return; - } - - for entry in WalkDir::new(root) - .into_iter() - .filter_entry(should_scan_framework_entry) - .flatten() - { - if !entry.file_type().is_file() || !is_header_file(entry.path()) { - continue; - } - if let Some(name) = entry.path().file_name().and_then(|name| name.to_str()) { - headers.insert(name.to_string()); - } - } -} - -fn collect_included_headers(root: &Path, headers: &mut HashSet) { - collect_included_headers_with_filter(root, headers, should_scan_entry); -} - -fn collect_framework_included_headers(root: &Path, headers: &mut HashSet) { - collect_included_headers_with_filter(root, headers, should_scan_framework_entry); -} - -fn collect_included_headers_with_filter( - root: &Path, - headers: &mut HashSet, - filter: fn(&DirEntry) -> bool, -) { - if !root.exists() { - return; - } - - for entry in WalkDir::new(root) - .into_iter() - .filter_entry(filter) - .flatten() - { - if !entry.file_type().is_file() || !is_source_or_header_file(entry.path()) { - continue; - } - let Ok(content) = std::fs::read_to_string(entry.path()) else { - continue; - }; - for line in content.lines() { - if let Some(header) = parse_include_header(line) { - headers.insert(header); - } - } - } -} - -fn should_scan_entry(entry: &DirEntry) -> bool { - let name = entry.file_name().to_string_lossy().to_lowercase(); - !matches!( - name.as_str(), - ".git" - | ".pio" - | ".fbuild" - | ".zap" - | ".build" - | "build" - | "target" - | ".venv" - | "venv" - | "node_modules" - | "__pycache__" - ) -} - -fn should_scan_framework_entry(entry: &DirEntry) -> bool { - if !should_scan_entry(entry) { - return false; - } - let name = entry.file_name().to_string_lossy().to_lowercase(); - !matches!( - name.as_str(), - "examples" | "example" | "extras" | "test" | "tests" | "fontconvert" - ) -} - -fn is_source_or_header_file(path: &Path) -> bool { - let ext = path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_lowercase(); - matches!( - ext.as_str(), - "c" | "cpp" | "cc" | "cxx" | "s" | "ino" | "h" | "hh" | "hpp" | "hxx" - ) -} - -fn is_header_file(path: &Path) -> bool { - let ext = path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_lowercase(); - matches!(ext.as_str(), "h" | "hh" | "hpp" | "hxx") -} - -fn parse_include_header(line: &str) -> Option { - let trimmed = line.trim_start(); - let directive = trimmed.strip_prefix('#')?.trim_start(); - let rest = directive.strip_prefix("include")?.trim_start(); - let mut chars = rest.chars(); - let opener = chars.next()?; - let closer = match opener { - '<' => '>', - '"' => '"', - _ => return None, - }; - let remainder = &rest[opener.len_utf8()..]; - let end = remainder.find(closer)?; - let include_path = &remainder[..end]; - Path::new(include_path) - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.to_string()) -} - impl BuildOrchestrator for TeensyOrchestrator { fn platform(&self) -> Platform { Platform::Teensy @@ -377,8 +158,9 @@ impl BuildOrchestrator for TeensyOrchestrator { .core_sources .retain(|p| p.file_name().map(|f| f != "Blink.cc").unwrap_or(true)); + let framework_libs = framework.get_framework_libraries(); let framework_library_sources = - resolve_teensy_framework_library_sources(&framework, ¶ms.project_dir, &ctx.src_dir); + resolve_framework_library_sources(&framework_libs, ¶ms.project_dir, &ctx.src_dir); if !framework_library_sources.is_empty() { tracing::info!( "Teensy framework library sources added: {}", @@ -549,182 +331,13 @@ mod tests { std::fs::create_dir_all(&build_dir).unwrap(); std::fs::create_dir_all(&project_dir).unwrap(); - let contract = - FastPathContract::for_project_outputs(&build_dir, &project_dir, Vec::::new()); + let contract = FastPathContract::for_project_outputs( + &build_dir, + &project_dir, + Vec::::new(), + ); assert_eq!(contract.watches().len(), 2); assert_eq!(contract.watches()[0].root, project_dir); assert_eq!(contract.watches()[1].root, build_dir.join("libs")); } - - #[test] - fn test_parse_include_header_extracts_basename() { - assert_eq!( - parse_include_header("#include "), - Some("SPI.h".to_string()) - ); - assert_eq!( - parse_include_header(" # include \"utility/foo.hpp\""), - Some("foo.hpp".to_string()) - ); - assert_eq!(parse_include_header("int x = 1;"), None); - } - - #[test] - fn test_resolve_teensy_framework_libraries_from_project_includes() { - let tmp = tempfile::TempDir::new().unwrap(); - let project_src = tmp.path().join("project").join("src"); - std::fs::create_dir_all(&project_src).unwrap(); - std::fs::write( - project_src.join("main.cpp"), - "#include \n#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 octo_dir = tmp - .path() - .join("framework") - .join("libraries") - .join("OctoWS2811"); - std::fs::create_dir_all(&octo_dir).unwrap(); - std::fs::write(octo_dir.join("OctoWS2811.h"), "").unwrap(); - std::fs::write(octo_dir.join("OctoWS2811.cpp"), "").unwrap(); - std::fs::write(octo_dir.join("OctoWS2811_imxrt.cpp"), "").unwrap(); - - let libraries = vec![ - TeensyFrameworkLibrary { - name: "OctoWS2811".to_string(), - dir: octo_dir.clone(), - include_dirs: vec![octo_dir.clone()], - source_files: vec![ - octo_dir.join("OctoWS2811.cpp"), - octo_dir.join("OctoWS2811_imxrt.cpp"), - ], - }, - TeensyFrameworkLibrary { - name: "SPI".to_string(), - dir: spi_dir.clone(), - include_dirs: vec![spi_dir.clone()], - source_files: vec![spi_dir.join("SPI.cpp")], - }, - ]; - - let mut sources = resolve_teensy_framework_library_sources_from_libraries( - &libraries, - std::slice::from_ref(&project_src), - ); - sources.sort(); - - assert_eq!( - sources, - vec![ - octo_dir.join("OctoWS2811.cpp"), - octo_dir.join("OctoWS2811_imxrt.cpp"), - spi_dir.join("SPI.cpp"), - ] - ); - } - - #[test] - fn test_resolve_teensy_framework_libraries_follows_transitive_includes() { - let tmp = tempfile::TempDir::new().unwrap(); - let project_src = tmp.path().join("project").join("src"); - std::fs::create_dir_all(&project_src).unwrap(); - std::fs::write(project_src.join("main.cpp"), "#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 wrapper_dir = tmp - .path() - .join("framework") - .join("libraries") - .join("NeedsSpi"); - std::fs::create_dir_all(&wrapper_dir).unwrap(); - std::fs::write(wrapper_dir.join("NeedsSpi.h"), "#include \n").unwrap(); - std::fs::write(wrapper_dir.join("NeedsSpi.cpp"), "").unwrap(); - - let libraries = vec![ - TeensyFrameworkLibrary { - name: "NeedsSpi".to_string(), - dir: wrapper_dir.clone(), - include_dirs: vec![wrapper_dir.clone()], - source_files: vec![wrapper_dir.join("NeedsSpi.cpp")], - }, - TeensyFrameworkLibrary { - name: "SPI".to_string(), - dir: spi_dir.clone(), - include_dirs: vec![spi_dir.clone()], - source_files: vec![spi_dir.join("SPI.cpp")], - }, - ]; - - let mut sources = resolve_teensy_framework_library_sources_from_libraries( - &libraries, - std::slice::from_ref(&project_src), - ); - sources.sort(); - - assert_eq!( - sources, - vec![wrapper_dir.join("NeedsSpi.cpp"), spi_dir.join("SPI.cpp")] - ); - } - - #[test] - fn test_resolve_teensy_framework_libraries_prefers_local_fastled_over_framework() { - let tmp = tempfile::TempDir::new().unwrap(); - let project_src = tmp.path().join("project").join("src"); - let project_lib = tmp - .path() - .join("project") - .join("lib") - .join("FastLED") - .join("src"); - std::fs::create_dir_all(&project_src).unwrap(); - std::fs::create_dir_all(&project_lib).unwrap(); - std::fs::write(project_src.join("main.cpp"), "#include \n").unwrap(); - std::fs::write(project_lib.join("FastLED.h"), "#include \n").unwrap(); - std::fs::write(project_lib.join("FastLED.cpp"), "").unwrap(); - - let framework_fastled_dir = tmp - .path() - .join("framework") - .join("libraries") - .join("FastLED"); - std::fs::create_dir_all(&framework_fastled_dir).unwrap(); - std::fs::write(framework_fastled_dir.join("FastLED.h"), "").unwrap(); - std::fs::write(framework_fastled_dir.join("FastLED.cpp"), "").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 libraries = vec![ - TeensyFrameworkLibrary { - name: "FastLED".to_string(), - dir: framework_fastled_dir.clone(), - include_dirs: vec![framework_fastled_dir.clone()], - source_files: vec![framework_fastled_dir.join("FastLED.cpp")], - }, - TeensyFrameworkLibrary { - name: "SPI".to_string(), - dir: spi_dir.clone(), - include_dirs: vec![spi_dir.clone()], - source_files: vec![spi_dir.join("SPI.cpp")], - }, - ]; - - let roots = vec![project_src, project_lib]; - let sources = resolve_teensy_framework_library_sources_from_libraries(&libraries, &roots); - - assert_eq!(sources, vec![spi_dir.join("SPI.cpp")]); - } } diff --git a/crates/fbuild-packages/src/library/framework_library.rs b/crates/fbuild-packages/src/library/framework_library.rs new file mode 100644 index 00000000..8af070cb --- /dev/null +++ b/crates/fbuild-packages/src/library/framework_library.rs @@ -0,0 +1,194 @@ +//! Shared model for a bundled Arduino-style framework library. +//! +//! Frameworks such as Teensyduino and STM32duino ship a `libraries/` directory +//! whose subdirectories each contain an Arduino library (e.g. `SPI`, `Wire`). +//! Every library exposes its own include dirs and source files. The types and +//! helpers here walk that directory layout so each platform's build +//! orchestrator can discover the libraries a sketch actually uses. + +use std::path::{Path, PathBuf}; + +/// A bundled framework library discovered under `libraries//`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrameworkLibrary { + pub name: String, + pub dir: PathBuf, + pub include_dirs: Vec, + pub source_files: Vec, +} + +/// Enumerate every library under `libraries_dir`. +/// +/// Returns libraries sorted by name; missing `libraries_dir` yields an empty +/// vec. +pub fn discover_framework_libraries(libraries_dir: &Path) -> Vec { + let mut libs = Vec::new(); + let Ok(entries) = std::fs::read_dir(libraries_dir) else { + return libs; + }; + + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + if name.is_empty() { + continue; + } + libs.push(FrameworkLibrary { + name, + include_dirs: library_include_dirs(&dir), + source_files: collect_library_sources(&dir), + dir, + }); + } + + libs.sort_by(|a, b| a.name.cmp(&b.name)); + libs +} + +/// Resolve the include search paths for a single library. +/// +/// Follows the Arduino library layout: prefer `src/` if present, otherwise +/// fall back to the library root. `utility/` and `include/` are added when +/// present to cover older / less-standard layouts. +pub fn library_include_dirs(lib_dir: &Path) -> Vec { + let mut dirs = Vec::new(); + let src = lib_dir.join("src"); + if src.is_dir() { + dirs.push(src); + } else { + dirs.push(lib_dir.to_path_buf()); + } + + let utility = lib_dir.join("utility"); + if utility.is_dir() { + dirs.push(utility); + } + let include = lib_dir.join("include"); + if include.is_dir() { + dirs.push(include); + } + dirs +} + +/// Collect every buildable source file from a library. +/// +/// Skips `examples/`, `tests/`, and `extras/` subtrees to keep user-facing +/// demos out of the build. +pub fn collect_library_sources(lib_dir: &Path) -> Vec { + let search_dir = { + let src = lib_dir.join("src"); + if src.is_dir() { + src + } else { + lib_dir.to_path_buf() + } + }; + + let mut sources = Vec::new(); + collect_library_sources_inner(&search_dir, &mut sources); + sources.sort(); + sources +} + +fn collect_library_sources_inner(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if matches!( + name.as_str(), + "example" | "examples" | "test" | "tests" | "extras" + ) { + continue; + } + collect_library_sources_inner(&path, out); + } else { + let ext = path + .extension() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s") { + out.push(path); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_include_dirs_prefers_src() { + let tmp = tempfile::TempDir::new().unwrap(); + let lib = tmp.path().join("SPI"); + std::fs::create_dir_all(lib.join("src")).unwrap(); + assert_eq!(library_include_dirs(&lib), vec![lib.join("src")]); + } + + #[test] + fn library_include_dirs_falls_back_to_root() { + let tmp = tempfile::TempDir::new().unwrap(); + let lib = tmp.path().join("SPI"); + std::fs::create_dir_all(&lib).unwrap(); + std::fs::write(lib.join("SPI.h"), "").unwrap(); + assert_eq!(library_include_dirs(&lib), vec![lib]); + } + + #[test] + fn collect_library_sources_skips_examples_and_extras() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("OctoWS2811.cpp"), "").unwrap(); + std::fs::create_dir_all(tmp.path().join("examples")).unwrap(); + std::fs::write(tmp.path().join("examples").join("Demo.cpp"), "").unwrap(); + std::fs::create_dir_all(tmp.path().join("extras")).unwrap(); + std::fs::write(tmp.path().join("extras").join("tool.c"), "").unwrap(); + + let sources = collect_library_sources(tmp.path()); + assert_eq!(sources, vec![tmp.path().join("OctoWS2811.cpp")]); + } + + #[test] + fn discover_framework_libraries_walks_each_subdirectory() { + let tmp = tempfile::TempDir::new().unwrap(); + let libs_dir = tmp.path().join("libraries"); + let spi = libs_dir.join("SPI").join("src"); + std::fs::create_dir_all(&spi).unwrap(); + std::fs::write(spi.join("SPI.h"), "").unwrap(); + std::fs::write(spi.join("SPI.cpp"), "").unwrap(); + + let wire = libs_dir.join("Wire"); + std::fs::create_dir_all(&wire).unwrap(); + std::fs::write(wire.join("Wire.h"), "").unwrap(); + + let libs = discover_framework_libraries(&libs_dir); + assert_eq!(libs.len(), 2); + assert_eq!(libs[0].name, "SPI"); + assert_eq!(libs[0].include_dirs, vec![spi.clone()]); + assert_eq!(libs[0].source_files, vec![spi.join("SPI.cpp")]); + assert_eq!(libs[1].name, "Wire"); + } + + #[test] + fn discover_framework_libraries_missing_dir_is_empty() { + let tmp = tempfile::TempDir::new().unwrap(); + let libs = discover_framework_libraries(&tmp.path().join("missing")); + assert!(libs.is_empty()); + } +} diff --git a/crates/fbuild-packages/src/library/mod.rs b/crates/fbuild-packages/src/library/mod.rs index 3a6e233b..487b75c8 100644 --- a/crates/fbuild-packages/src/library/mod.rs +++ b/crates/fbuild-packages/src/library/mod.rs @@ -12,6 +12,7 @@ pub mod cmsis_framework; pub mod esp32_framework; pub mod esp32_platform; pub mod esp8266_framework; +pub mod framework_library; pub mod library_compiler; pub mod library_downloader; pub mod library_info; @@ -38,6 +39,7 @@ pub use cmsis_framework::CmsisFramework; pub use esp32_framework::Esp32Framework; pub use esp32_platform::Esp32Platform; pub use esp8266_framework::Esp8266Framework; +pub use framework_library::FrameworkLibrary; pub use library_manager::LibraryResult; pub use library_spec::LibrarySpec; pub use nrf52_core::Nrf52Cores; @@ -47,4 +49,4 @@ pub use sam_core::SamCores; pub use samd_core::SamdCores; pub use silabs_core::SilabsCores; pub use stm32_core::Stm32Cores; -pub use teensy_core::{TeensyCores, TeensyFrameworkLibrary}; +pub use teensy_core::TeensyCores; diff --git a/crates/fbuild-packages/src/library/stm32_core.rs b/crates/fbuild-packages/src/library/stm32_core.rs index 84b5af34..8b9a7790 100644 --- a/crates/fbuild-packages/src/library/stm32_core.rs +++ b/crates/fbuild-packages/src/library/stm32_core.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; +use crate::library::framework_library::{discover_framework_libraries, FrameworkLibrary}; use crate::{CacheSubdir, Framework, PackageBase, PackageInfo}; const STM32_CORE_VERSION: &str = "2.9.0"; @@ -102,6 +103,19 @@ impl Stm32Cores { let core_dir = self.get_core_dir(core_name); collect_sources(&core_dir) } + + /// List bundled Arduino_Core_STM32 framework libraries (SPI, Wire, ...). + pub fn get_framework_libraries(&self) -> Vec { + discover_framework_libraries(&self.get_libraries_dir()) + } + + /// All include directories needed to make bundled framework headers visible. + pub fn get_framework_library_include_dirs(&self) -> Vec { + self.get_framework_libraries() + .into_iter() + .flat_map(|lib| lib.include_dirs) + .collect() + } } impl crate::Package for Stm32Cores { diff --git a/crates/fbuild-packages/src/library/teensy_core.rs b/crates/fbuild-packages/src/library/teensy_core.rs index 97e66844..a2cfd5ad 100644 --- a/crates/fbuild-packages/src/library/teensy_core.rs +++ b/crates/fbuild-packages/src/library/teensy_core.rs @@ -6,21 +6,13 @@ use std::path::{Path, PathBuf}; +use crate::library::framework_library::{discover_framework_libraries, FrameworkLibrary}; use crate::{CacheSubdir, Framework, PackageBase, PackageInfo}; /// Framework package used by platform-teensy 5.1.0. const TEENSY_CORE_VERSION: &str = "1.160.0"; const TEENSY_CORE_URL: &str = "https://dl.registry.platformio.org/download/platformio/tool/framework-arduinoteensy/1.160.0/framework-arduinoteensy-1.160.0.tar.gz"; -/// A bundled Teensyduino framework library. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TeensyFrameworkLibrary { - pub name: String, - pub dir: PathBuf, - pub include_dirs: Vec, - pub source_files: Vec, -} - /// Teensy cores framework manager. pub struct TeensyCores { base: PackageBase, @@ -135,36 +127,8 @@ impl TeensyCores { } /// List bundled Teensyduino framework libraries. - pub fn get_framework_libraries(&self) -> Vec { - let libraries_dir = self.get_libraries_dir(); - let mut libs = Vec::new(); - let Ok(entries) = std::fs::read_dir(&libraries_dir) else { - return libs; - }; - - for entry in entries.flatten() { - let dir = entry.path(); - if !dir.is_dir() { - continue; - } - let name = dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default() - .to_string(); - if name.is_empty() { - continue; - } - libs.push(TeensyFrameworkLibrary { - name, - include_dirs: library_include_dirs(&dir), - source_files: collect_library_sources(&dir), - dir, - }); - } - - libs.sort_by(|a, b| a.name.cmp(&b.name)); - libs + pub fn get_framework_libraries(&self) -> Vec { + discover_framework_libraries(&self.get_libraries_dir()) } /// All include directories needed to make bundled framework headers visible. @@ -299,75 +263,6 @@ fn collect_sources(dir: &Path) -> Vec { sources } -fn library_include_dirs(lib_dir: &Path) -> Vec { - let mut dirs = Vec::new(); - let src = lib_dir.join("src"); - if src.is_dir() { - dirs.push(src); - } else { - dirs.push(lib_dir.to_path_buf()); - } - - let utility = lib_dir.join("utility"); - if utility.is_dir() { - dirs.push(utility); - } - let include = lib_dir.join("include"); - if include.is_dir() { - dirs.push(include); - } - dirs -} - -fn collect_library_sources(lib_dir: &Path) -> Vec { - let search_dir = { - let src = lib_dir.join("src"); - if src.is_dir() { - src - } else { - lib_dir.to_path_buf() - } - }; - - let mut sources = Vec::new(); - collect_library_sources_inner(&search_dir, &mut sources); - sources.sort(); - sources -} - -fn collect_library_sources_inner(dir: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); - if matches!( - name.as_str(), - "example" | "examples" | "test" | "tests" | "extras" - ) { - continue; - } - collect_library_sources_inner(&path, out); - } else { - let ext = path - .extension() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); - if matches!(ext.as_str(), "c" | "cpp" | "cc" | "cxx" | "s") { - out.push(path); - } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -438,27 +333,4 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Arduino.h")); } - - #[test] - fn test_library_include_dirs_for_root_layout() { - let tmp = tempfile::TempDir::new().unwrap(); - let lib = tmp.path().join("SPI"); - std::fs::create_dir_all(&lib).unwrap(); - std::fs::write(lib.join("SPI.h"), "").unwrap(); - - assert_eq!(library_include_dirs(&lib), vec![lib]); - } - - #[test] - fn test_collect_library_sources_skips_examples_and_extras() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("OctoWS2811.cpp"), "").unwrap(); - std::fs::create_dir_all(tmp.path().join("examples")).unwrap(); - std::fs::write(tmp.path().join("examples").join("Demo.cpp"), "").unwrap(); - std::fs::create_dir_all(tmp.path().join("extras")).unwrap(); - std::fs::write(tmp.path().join("extras").join("tool.c"), "").unwrap(); - - let sources = collect_library_sources(tmp.path()); - assert_eq!(sources, vec![tmp.path().join("OctoWS2811.cpp")]); - } }