Skip to content

Commit 1e6d921

Browse files
zackeesclaude
andauthored
fix(ino): never hoist an #include out of its #if (#1443)
* fix(ino): never hoist an #include out of its #if The .ino -> .ino.cpp conversion (#1275) moved every line starting with `#include` into the prelude and blanked it in the body, with no notion of preprocessor conditionals. A guarded include such as #if defined(FL_IS_TEENSY) #include <Audio.h> #endif was emitted bare above the sketch, so every non-Teensy build of FastLED's root project (Sailboat) failed with "Audio.h: No such file". The same scan also reordered a #define ahead of the header it configures, matched `#include` inside /* */ comments, and missed `# include`. Hoist only each tab's leading preprocessor region: the blank lines, comments and directives before the first line of code, cut back to where #if nesting is closed. - The first tab's region moves verbatim and in order (#if blocks, #defines and all). It precedes all other code anyway, so semantics are unchanged, and headers still precede the prototypes (the #1275 goal). - Later tabs contribute only their unconditional #includes; their other directives stay in place so they cannot change what earlier tabs see. - Includes after the first line of code stay where they are, as with arduino-cli. Fixes #1440 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ino): move the #1440 hoisting tests to their own module tests.rs crossed the 1000-LOC gate; the new cases live in source_scanner/tests_include_hoisting.rs and reuse setup_project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ino): keep a continued #include in a later tab whole A later tab's `#include \\` + `<Wire.h>` hoisted only the continuation line, leaving a bare `<Wire.h>` in the prelude and a dangling directive in the body. Mark continuation lines and hoist only single-line includes from later tabs. Review follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: apply formatting Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2fd322d commit 1e6d921

3 files changed

Lines changed: 315 additions & 40 deletions

File tree

crates/fbuild-build-engine/src/source_scanner.rs

Lines changed: 171 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -349,17 +349,14 @@ impl SourceScanner {
349349
})
350350
.collect::<fbuild_core::Result<Vec<_>>>()?;
351351

352-
// Hoist every tab's `#include` directives into the prelude so that
352+
// Hoist each tab's leading preprocessor lines into the prelude so that
353353
// auto-generated prototypes can reference types from library headers
354354
// (FastLED/fbuild#1275 — arduino-cli places prototypes *after* the
355-
// include block for exactly this reason). Replace each hoisted
356-
// `#include` line with a blank line in the body to preserve line
357-
// numbering for diagnostics.
358-
let sketch_includes = hoist_include_directives(&contents);
359-
let stripped_contents: Vec<String> = contents
360-
.iter()
361-
.map(|c| strip_include_directives(c))
362-
.collect();
355+
// include block for exactly this reason), without lifting an
356+
// `#include` out of its `#if` (FastLED/fbuild#1440). Hoisted lines
357+
// become blank lines in the body to preserve line numbering for
358+
// diagnostics.
359+
let (sketch_includes, stripped_contents) = hoist_leading_preprocessor(&contents);
363360

364361
// Prototype extraction needs to see every tab's code, so it operates
365362
// on the plain concatenation (no #line noise). We feed it the
@@ -787,44 +784,176 @@ fn walk_sources(dir: &Path) -> Vec<PathBuf> {
787784
files
788785
}
789786

790-
/// Extract every `#include` directive across all tabs, deduplicated and in
791-
/// first-seen order, for hoisting into the prelude (FastLED/fbuild#1275).
792-
fn hoist_include_directives(contents: &[String]) -> Vec<String> {
793-
let mut seen = HashSet::new();
794-
let mut includes = Vec::new();
795-
for content in contents {
796-
for line in content.lines() {
797-
let trimmed = line.trim();
798-
if is_include_directive(trimmed) {
799-
let normalized = trimmed.to_string();
800-
if seen.insert(normalized.clone()) {
801-
includes.push(normalized);
787+
/// One line of a tab's leading preprocessor region.
788+
struct LeadingLine {
789+
index: usize,
790+
/// `#if` nesting depth the line sits at (0 = unconditional).
791+
depth: usize,
792+
/// Part of an `#include` directive.
793+
is_include: bool,
794+
/// A `\`-continuation of the directive on a previous line.
795+
is_continuation: bool,
796+
}
797+
798+
/// The lines before a tab's first line of code -- blank lines, comments and
799+
/// preprocessor directives (with `\` continuations) -- cut back to the last
800+
/// point where `#if` nesting is closed, so a region never ends inside a
801+
/// conditional block.
802+
fn leading_preprocessor_region(source: &str) -> Vec<LeadingLine> {
803+
let mut lines = Vec::new();
804+
let mut depth = 0usize;
805+
let mut in_block_comment = false;
806+
let mut continuation: Option<(usize, bool)> = None;
807+
let mut boundary = 0usize;
808+
809+
for (index, line) in source.lines().enumerate() {
810+
let trimmed = line.trim();
811+
if let Some((cont_depth, cont_include)) = continuation {
812+
lines.push(LeadingLine {
813+
index,
814+
depth: cont_depth,
815+
is_include: cont_include,
816+
is_continuation: true,
817+
});
818+
if !trimmed.ends_with('\\') {
819+
continuation = None;
820+
}
821+
} else if in_block_comment {
822+
if let Some(end) = trimmed.find("*/") {
823+
if !is_comment_tail(&trimmed[end + 2..]) {
824+
break;
825+
}
826+
in_block_comment = false;
827+
}
828+
lines.push(LeadingLine {
829+
index,
830+
depth,
831+
is_include: false,
832+
is_continuation: false,
833+
});
834+
} else if trimmed.is_empty() || trimmed.starts_with("//") {
835+
lines.push(LeadingLine {
836+
index,
837+
depth,
838+
is_include: false,
839+
is_continuation: false,
840+
});
841+
} else if let Some(after_open) = trimmed.strip_prefix("/*") {
842+
match after_open.find("*/") {
843+
Some(end) if !is_comment_tail(&after_open[end + 2..]) => break,
844+
Some(_) => {}
845+
None => in_block_comment = true,
846+
}
847+
lines.push(LeadingLine {
848+
index,
849+
depth,
850+
is_include: false,
851+
is_continuation: false,
852+
});
853+
} else if let Some(directive) = trimmed.strip_prefix('#') {
854+
let name: String = directive
855+
.trim_start()
856+
.chars()
857+
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
858+
.collect();
859+
let line_depth = match name.as_str() {
860+
"if" | "ifdef" | "ifndef" => {
861+
depth += 1;
862+
depth - 1
863+
}
864+
"elif" | "else" => depth.saturating_sub(1),
865+
"endif" => {
866+
if depth == 0 {
867+
break;
868+
}
869+
depth -= 1;
870+
depth
802871
}
872+
_ => depth,
873+
};
874+
let is_include = name == "include";
875+
lines.push(LeadingLine {
876+
index,
877+
depth: line_depth,
878+
is_include,
879+
is_continuation: false,
880+
});
881+
if trimmed.ends_with('\\') {
882+
continuation = Some((line_depth, is_include));
803883
}
884+
} else {
885+
break;
886+
}
887+
if depth == 0 && continuation.is_none() && !in_block_comment {
888+
boundary = lines.len();
804889
}
805890
}
806-
includes
891+
lines.truncate(boundary);
892+
lines
807893
}
808894

809-
/// Replace every `#include` line with an empty line so that line numbering
810-
/// is preserved when the hoisted directives are moved to the prelude
811-
/// (FastLED/fbuild#1275).
812-
fn strip_include_directives(source: &str) -> String {
813-
source
814-
.lines()
815-
.map(|line| {
816-
if is_include_directive(line.trim()) {
817-
""
818-
} else {
819-
line
820-
}
821-
})
822-
.collect::<Vec<_>>()
823-
.join("\n")
895+
/// True when what follows a closing `*/` on the same line is nothing but
896+
/// whitespace or a `//` comment.
897+
fn is_comment_tail(rest: &str) -> bool {
898+
let rest = rest.trim();
899+
rest.is_empty() || rest.starts_with("//")
824900
}
825901

826-
fn is_include_directive(trimmed: &str) -> bool {
827-
trimmed.starts_with("#include")
902+
/// Move each tab's leading preprocessor lines into the prelude so the
903+
/// auto-generated prototypes, which sit in the prelude, can use types from
904+
/// the sketch's headers (FastLED/fbuild#1275). Moved lines become blank lines
905+
/// in the body so diagnostics keep their line numbers.
906+
///
907+
/// Only lines *before the first line of code* move, and an `#include` never
908+
/// leaves its conditional (FastLED/fbuild#1440): a Teensy-only
909+
/// `#if ... #include <Audio.h> #endif` used to be hoisted bare and compiled
910+
/// on every board.
911+
///
912+
/// - The first tab's region moves verbatim and in order -- includes, `#if`
913+
/// blocks, `#define`s and comments. It precedes all other code anyway, so a
914+
/// `#define` that configures a later header still precedes that header.
915+
/// - Later tabs contribute only their unconditional `#include`s. Their other
916+
/// directives stay put: moving a later tab's `#define` above the first
917+
/// tab's code would change what that code sees.
918+
///
919+
/// Unconditional includes are de-duplicated by trimmed text across tabs.
920+
/// Returns the hoisted lines and each tab's body with those lines blanked.
921+
fn hoist_leading_preprocessor(contents: &[String]) -> (Vec<String>, Vec<String>) {
922+
let mut seen_includes = HashSet::new();
923+
let mut hoisted = Vec::new();
924+
let mut stripped = Vec::with_capacity(contents.len());
925+
926+
for (tab, content) in contents.iter().enumerate() {
927+
let source_lines: Vec<&str> = content.lines().collect();
928+
let mut moved = HashSet::new();
929+
for leading in leading_preprocessor_region(content) {
930+
let line = source_lines[leading.index];
931+
let unconditional_include = leading.is_include && leading.depth == 0;
932+
if tab == 0 {
933+
if unconditional_include {
934+
seen_includes.insert(line.trim().to_string());
935+
}
936+
hoisted.push(line.to_string());
937+
moved.insert(leading.index);
938+
} else if unconditional_include
939+
&& !leading.is_continuation
940+
&& !line.trim_end().ends_with('\\')
941+
{
942+
if seen_includes.insert(line.trim().to_string()) {
943+
hoisted.push(line.trim().to_string());
944+
}
945+
moved.insert(leading.index);
946+
}
947+
}
948+
let body = source_lines
949+
.iter()
950+
.enumerate()
951+
.map(|(i, line)| if moved.contains(&i) { "" } else { *line })
952+
.collect::<Vec<_>>()
953+
.join("\n");
954+
stripped.push(body);
955+
}
956+
(hoisted, stripped)
828957
}
829958

830959
/// Extract function prototypes from concatenated .ino source using a C++ parser.
@@ -1220,3 +1349,6 @@ fn trim_trailing_spaces(text: &mut String) {
12201349

12211350
#[cfg(test)]
12221351
mod tests;
1352+
1353+
#[cfg(test)]
1354+
mod tests_include_hoisting;

crates/fbuild-build-engine/src/source_scanner/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use super::*;
55
use std::fs;
66
use tempfile::TempDir;
77

8-
fn setup_project(src_files: &[(&str, &str)]) -> (TempDir, PathBuf, PathBuf) {
8+
pub(super) fn setup_project(src_files: &[(&str, &str)]) -> (TempDir, PathBuf, PathBuf) {
99
let tmp = TempDir::new().unwrap();
1010
let src_dir = tmp.path().join("src");
1111
let build_dir = tmp.path().join("build");
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! FastLED/fbuild#1440: `.ino` include hoisting must never lift an `#include`
2+
//! out of its `#if`. Split from `tests.rs` to keep it under the 1000-LOC gate.
3+
4+
use super::tests::setup_project;
5+
use super::*;
6+
use std::fs;
7+
8+
/// Generated `.ino.cpp` for a single-tab sketch, split at its `#line 1`.
9+
fn generated_prelude_and_body(sketch: &str) -> (String, String) {
10+
let (_tmp, src_dir, build_dir) = setup_project(&[("sketch.ino", sketch)]);
11+
let scanner = SourceScanner::new(&src_dir, &build_dir);
12+
let sources = scanner.scan_sketch_sources().unwrap();
13+
let content = fs::read_to_string(&sources[0]).unwrap();
14+
let (prelude, body) = content
15+
.split_once("#line 1 \"src/sketch.ino\"\n")
16+
.expect("generated file carries a #line 1 directive");
17+
(prelude.to_string(), body.to_string())
18+
}
19+
20+
#[test]
21+
fn test_conditional_include_stays_inside_its_if() {
22+
// The FastLED Sailboat shape that broke every non-Teensy root build.
23+
let (prelude, body) = generated_prelude_and_body(
24+
"#include <FastLED.h>\n\n#if defined(FL_IS_TEENSY)\n#include <Audio.h>\n#endif\n#include \"fl/ui/ui.h\"\n\nint helper(int x) { return x; }\nvoid setup() {}\nvoid loop() {}\n",
25+
);
26+
let lines: Vec<&str> = prelude.lines().collect();
27+
let if_pos = lines
28+
.iter()
29+
.position(|l| *l == "#if defined(FL_IS_TEENSY)")
30+
.expect("the #if moves with its include");
31+
assert_eq!(lines[if_pos + 1], "#include <Audio.h>");
32+
assert_eq!(lines[if_pos + 2], "#endif");
33+
// Exactly one Audio.h, and only inside the guard.
34+
assert_eq!(prelude.matches("#include <Audio.h>").count(), 1);
35+
assert!(!body.contains("#include <Audio.h>"));
36+
// Headers after the block still precede the prototypes.
37+
let ui_pos = prelude.find("#include \"fl/ui/ui.h\"").unwrap();
38+
let proto_pos = prelude
39+
.find("// Auto-generated function prototypes")
40+
.unwrap();
41+
assert!(ui_pos < proto_pos);
42+
}
43+
44+
#[test]
45+
fn test_define_before_include_keeps_its_order() {
46+
let (prelude, _body) = generated_prelude_and_body(
47+
"#define FASTLED_CONFIG_KNOB 1\n#include <FastLED.h>\n\nvoid setup() {}\nvoid loop() {}\n",
48+
);
49+
let define_pos = prelude.find("#define FASTLED_CONFIG_KNOB 1").unwrap();
50+
let include_pos = prelude.find("#include <FastLED.h>").unwrap();
51+
assert!(
52+
define_pos < include_pos,
53+
"a #define that configures a header must still precede it"
54+
);
55+
}
56+
57+
#[test]
58+
fn test_include_after_first_code_line_stays_in_place() {
59+
let (prelude, body) = generated_prelude_and_body(
60+
"#include <FastLED.h>\nint counter = 0;\n#include \"late.h\"\n\nvoid setup() {}\nvoid loop() {}\n",
61+
);
62+
assert!(!prelude.contains("late.h"));
63+
let body_lines: Vec<&str> = body.lines().collect();
64+
assert_eq!(body_lines[0], "", "the leading include is blanked");
65+
assert_eq!(body_lines[1], "int counter = 0;");
66+
assert_eq!(body_lines[2], "#include \"late.h\"", "left on its own line");
67+
}
68+
69+
#[test]
70+
fn test_spaced_include_directive_is_recognised() {
71+
let (prelude, body) =
72+
generated_prelude_and_body("# include <FastLED.h>\nvoid setup() {}\nvoid loop() {}\n");
73+
assert!(prelude.contains("# include <FastLED.h>"));
74+
assert!(!body.contains("include <FastLED.h>"));
75+
}
76+
77+
#[test]
78+
fn test_include_inside_block_comment_is_not_a_directive() {
79+
let (prelude, body) = generated_prelude_and_body(
80+
"/*\n#include <Nope.h>\n*/\n#include <FastLED.h>\nvoid setup() {}\nvoid loop() {}\n",
81+
);
82+
// The comment moves verbatim, so the commented-out include is still
83+
// inside a comment -- never a bare directive.
84+
let comment_open = prelude.find("/*").unwrap();
85+
let nope = prelude.find("#include <Nope.h>").unwrap();
86+
let comment_close = prelude.find("*/").unwrap();
87+
assert!(comment_open < nope && nope < comment_close);
88+
assert!(!body.contains("Nope.h"));
89+
}
90+
91+
#[test]
92+
fn test_later_tab_hoists_only_unconditional_includes() {
93+
let (_tmp, src_dir, build_dir) = setup_project(&[
94+
(
95+
"main.ino",
96+
"#include <FastLED.h>\nvoid setup() {}\nvoid loop() {}\n",
97+
),
98+
(
99+
"tab.ino",
100+
"#define TAB_ONLY 1\n#include <Wire.h>\n#ifdef ARDUINO_TEENSY41\n#include <Audio.h>\n#endif\nvoid helper() {}\n",
101+
),
102+
]);
103+
let scanner = SourceScanner::new(&src_dir, &build_dir);
104+
let sources = scanner.scan_sketch_sources().unwrap();
105+
let content = fs::read_to_string(&sources[0]).unwrap();
106+
let (prelude, rest) = content.split_once("#line 1 \"src/main.ino\"\n").unwrap();
107+
let (_, tab_body) = rest.split_once("#line 1 \"src/tab.ino\"\n").unwrap();
108+
109+
assert!(prelude.contains("#include <Wire.h>"));
110+
// A later tab's #define and guarded include stay in that tab, in order.
111+
assert!(!prelude.contains("TAB_ONLY"));
112+
assert!(!prelude.contains("Audio.h"));
113+
let tab_lines: Vec<&str> = tab_body.lines().collect();
114+
assert_eq!(tab_lines[0], "#define TAB_ONLY 1");
115+
assert_eq!(tab_lines[1], "", "the unconditional include is blanked");
116+
assert_eq!(tab_lines[2], "#ifdef ARDUINO_TEENSY41");
117+
assert_eq!(tab_lines[3], "#include <Audio.h>");
118+
assert_eq!(tab_lines[4], "#endif");
119+
}
120+
121+
#[test]
122+
fn test_later_tab_continued_include_stays_whole() {
123+
// A `\`-continued include in a later tab must not be split: hoisting its
124+
// final line alone left a bare `<Wire.h>` in the prelude and a dangling
125+
// `#include \` in the body.
126+
let (_tmp, src_dir, build_dir) = setup_project(&[
127+
(
128+
"main.ino",
129+
"#include <FastLED.h>\nvoid setup() {}\nvoid loop() {}\n",
130+
),
131+
("tab.ino", "#include \\\n<Wire.h>\nvoid helper() {}\n"),
132+
]);
133+
let scanner = SourceScanner::new(&src_dir, &build_dir);
134+
let sources = scanner.scan_sketch_sources().unwrap();
135+
let content = fs::read_to_string(&sources[0]).unwrap();
136+
let (prelude, rest) = content.split_once("#line 1 \"src/main.ino\"\n").unwrap();
137+
let (_, tab_body) = rest.split_once("#line 1 \"src/tab.ino\"\n").unwrap();
138+
139+
assert!(!prelude.contains("Wire.h"));
140+
let tab_lines: Vec<&str> = tab_body.lines().collect();
141+
assert_eq!(tab_lines[0], "#include \\");
142+
assert_eq!(tab_lines[1], "<Wire.h>");
143+
}

0 commit comments

Comments
 (0)