From df190dc2a3344e629a71d088a3ffd3dd7ba18ebf Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 19 Jun 2026 12:50:00 -0700 Subject: [PATCH] feat: match upstream multiple ino ordering Closes #647 --- crates/fbuild-build/src/source_scanner.rs | 149 ++++++++++++++++++++- docs/reference/platformio-compatibility.md | 31 +++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/crates/fbuild-build/src/source_scanner.rs b/crates/fbuild-build/src/source_scanner.rs index a45f84b0..fc52934d 100644 --- a/crates/fbuild-build/src/source_scanner.rs +++ b/crates/fbuild-build/src/source_scanner.rs @@ -5,6 +5,7 @@ //! Arduino.h include when the active include roots provide that header. use regex::Regex; +use std::cmp::Ordering; use std::collections::HashSet; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -137,7 +138,7 @@ impl SourceScanner { // If main.cpp exists and includes .ino files, skip preprocessing — // the .ino content is already compiled via #include in main.cpp. if !ino_files.is_empty() && !has_main_cpp { - ino_files.sort(); + let ino_files = order_ino_files(&self.src_dir, ino_files); let preprocessed = self.preprocess_ino_files(&ino_files, arduino_header_available(include_roots))?; sources.insert(0, preprocessed); @@ -244,7 +245,7 @@ impl SourceScanner { /// Preprocess .ino files into a single .cpp file. /// - /// 1. Concatenate .ino files (alphabetically sorted) + /// 1. Concatenate .ino files (primary sketch first, then tabs alphabetically) /// 2. Add `#include ` at top when available /// 3. Extract function prototypes /// 4. Add prototypes before first function definition @@ -398,6 +399,80 @@ fn arduino_header_available(include_roots: &[&Path]) -> bool { .any(|root| root.join("Arduino.h").is_file()) } +fn order_ino_files(src_dir: &Path, mut ino_files: Vec) -> Vec { + ino_files.sort_by(|a, b| compare_ino_paths(a, b)); + + if let Some(primary_index) = find_primary_ino_index(src_dir, &ino_files) { + let primary = ino_files.remove(primary_index); + ino_files.insert(0, primary); + } + + ino_files +} + +fn find_primary_ino_index(src_dir: &Path, ino_files: &[PathBuf]) -> Option { + for primary_stem in primary_ino_stems(src_dir) { + if let Some(index) = ino_files + .iter() + .position(|path| file_stem_eq_ignore_ascii_case(path, &primary_stem)) + { + return Some(index); + } + } + + let setup_or_loop = Regex::new(r"(?m)\bvoid\s+(setup|loop)\s*\(").expect("valid regex"); + ino_files.iter().position(|path| { + std::fs::read_to_string(path) + .map(|content| setup_or_loop.is_match(&content)) + .unwrap_or(false) + }) +} + +fn primary_ino_stems(src_dir: &Path) -> Vec { + let src_name = src_dir + .file_name() + .map(|name| name.to_string_lossy().to_string()); + + let mut stems = Vec::new(); + if let Some(src_name) = src_name { + if src_name.eq_ignore_ascii_case("src") { + stems.push("main".to_string()); + if let Some(project_name) = src_dir + .parent() + .and_then(Path::file_name) + .map(|name| name.to_string_lossy().to_string()) + { + stems.push(project_name); + } + } else { + stems.push(src_name); + } + } + + stems +} + +fn file_stem_eq_ignore_ascii_case(path: &Path, expected: &str) -> bool { + path.file_stem() + .map(|stem| stem.to_string_lossy().eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + +fn compare_ino_paths(a: &Path, b: &Path) -> Ordering { + let a_name = file_name_for_sort(a); + let b_name = file_name_for_sort(b); + a_name + .to_ascii_lowercase() + .cmp(&b_name.to_ascii_lowercase()) + .then_with(|| a_name.cmp(&b_name)) +} + +fn file_name_for_sort(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() +} + fn compile_source_filter_pattern(pattern: &str) -> fbuild_core::Result { let normalized = pattern.replace('\\', "/"); let regex_body = if normalized == "*" { @@ -644,6 +719,76 @@ mod tests { assert!(content.contains("helperB")); } + #[test] + fn test_scan_multiple_ino_files_uses_platformio_main_first() { + let (_tmp, src_dir, build_dir) = setup_project(&[ + ("z_tab.ino", "void zTab() {}\n"), + ("main.ino", "void setup() {}\nvoid loop() {}\n"), + ("a_tab.ino", "void aTab() {}\n"), + ]); + let scanner = SourceScanner::new(&src_dir, &build_dir); + + let sources = scanner.scan_sketch_sources().unwrap(); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("main.ino.cpp")); + let content = fs::read_to_string(&sources[0]).unwrap(); + + let main_pos = content.find("void setup()").unwrap(); + let a_pos = content.find("void aTab()").unwrap(); + let z_pos = content.find("void zTab()").unwrap(); + assert!(main_pos < a_pos); + assert!(a_pos < z_pos); + } + + #[test] + fn test_scan_multiple_ino_files_uses_arduino_named_primary_first() { + let tmp = TempDir::new().unwrap(); + let src_dir = tmp.path().join("Blink"); + let build_dir = tmp.path().join("build"); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&build_dir).unwrap(); + fs::write(src_dir.join("z_tab.ino"), "void zTab() {}\n").unwrap(); + fs::write( + src_dir.join("Blink.ino"), + "void setup() {}\nvoid loop() {}\n", + ) + .unwrap(); + fs::write(src_dir.join("a_tab.ino"), "void aTab() {}\n").unwrap(); + let scanner = SourceScanner::new(&src_dir, &build_dir); + + let sources = scanner.scan_sketch_sources().unwrap(); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("Blink.ino.cpp")); + let content = fs::read_to_string(&sources[0]).unwrap(); + + let primary_pos = content.find("void setup()").unwrap(); + let a_pos = content.find("void aTab()").unwrap(); + let z_pos = content.find("void zTab()").unwrap(); + assert!(primary_pos < a_pos); + assert!(a_pos < z_pos); + } + + #[test] + fn test_scan_multiple_ino_files_falls_back_to_setup_loop_primary() { + let (_tmp, src_dir, build_dir) = setup_project(&[ + ("a_tab.ino", "void aTab() {}\n"), + ("z_entry.ino", "void setup() {}\nvoid loop() {}\n"), + ("b_tab.ino", "void bTab() {}\n"), + ]); + let scanner = SourceScanner::new(&src_dir, &build_dir); + + let sources = scanner.scan_sketch_sources().unwrap(); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("z_entry.ino.cpp")); + let content = fs::read_to_string(&sources[0]).unwrap(); + + let primary_pos = content.find("void setup()").unwrap(); + let a_pos = content.find("void aTab()").unwrap(); + let b_pos = content.find("void bTab()").unwrap(); + assert!(primary_pos < a_pos); + assert!(a_pos < b_pos); + } + #[test] fn test_scan_mixed_sources() { let (_tmp, src_dir, build_dir) = setup_project(&[ diff --git a/docs/reference/platformio-compatibility.md b/docs/reference/platformio-compatibility.md index 24288c6d..b12400be 100644 --- a/docs/reference/platformio-compatibility.md +++ b/docs/reference/platformio-compatibility.md @@ -61,3 +61,34 @@ Implementation details and the original decision matrix are in `fbuild ci` is a drop-in replacement for `pio ci` for supported workflows. See the [`fbuild ci` reference](cli.md#fbuild-ci) for flag mapping and examples. + +## Multiple `.ino` Files + +Arduino CLI documents sketch preprocessing as concatenating `.ino` and `.pde` +files into one generated `.cpp`: the file matching the sketch folder name comes +first, then the remaining files are appended alphabetically. + +PlatformIO preprocesses top-level `PROJECT_SRC_DIR/*.ino` and `*.pde` files +through its `InoToCPPConverter`. In PlatformIO projects this commonly makes +`src/main.ino` the primary file; when there is no named primary, PlatformIO +detects a file containing `setup()` or `loop()` and treats that file as the main +input. The generated output name is based on the selected main `.ino` path. + +fbuild follows that combined compatibility rule: + +- Arduino-style sketch folders use `/.ino` as the + primary file when present. +- PlatformIO-style `src/` folders use `src/main.ino` as the primary file when + present, then fall back to a file containing `setup()` or `loop()`. +- Additional `.ino` tabs are concatenated after the primary file in + case-insensitive alphabetical order, with exact filename order as the tie + breaker. +- The generated file is named `.ino.cpp`, matching the chosen primary + `.ino` stem. + +References: + +- Arduino CLI sketch build process: + +- PlatformIO `InoToCPPConverter`: +