Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 147 additions & 2 deletions crates/fbuild-build/src/source_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <Arduino.h>` at top when available
/// 3. Extract function prototypes
/// 4. Add prototypes before first function definition
Expand Down Expand Up @@ -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<PathBuf>) -> Vec<PathBuf> {
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<usize> {
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<String> {
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<Regex> {
let normalized = pattern.replace('\\', "/");
let regex_body = if normalized == "*" {
Expand Down Expand Up @@ -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(&[
Expand Down
31 changes: 31 additions & 0 deletions docs/reference/platformio-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sketch-folder>/<sketch-folder>.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 `<primary>.ino.cpp`, matching the chosen primary
`.ino` stem.

References:

- Arduino CLI sketch build process:
<https://arduino.github.io/arduino-cli/1.5/sketch-build-process/#pre-processing>
- PlatformIO `InoToCPPConverter`:
<https://github.com/platformio/platformio-core/blob/develop/platformio/builder/tools/pioino.py>
Loading