diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 86fbbf8c..f9a0d94e 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -1360,11 +1360,27 @@ class ESPIDFCompiler: ) return ['user_libs_all'], header_to_comp - def _detect_external_includes(self, code: str) -> list[str]: - """Return library header names that are likely from external libraries.""" + def _detect_external_includes( + self, code: str, own_files: set[str] | None = None + ) -> list[str]: + """Return library header names that are likely from external libraries. + + BOTH include forms count. Arduino treats `#include "Lib.h"` and + `#include ` alike for libraries, and vendors' own examples lean on + the quoted form — M5Stack ships `#include "M5Cardputer.h"` in theirs. Only + scanning the angled form meant such a sketch never reached the library + resolver at all and died on `fatal error: M5Cardputer.h: No such file`, + while the very same sketch with angle brackets built fine. + + `own_files` are the sketch's own file names; a quoted include naming one + of them is a project-local header, not a library. + """ headers = [] - for m in re.finditer(r'#\s*include\s*<([^>]+)>', code): - h = m.group(1) + own = own_files or set() + for m in re.finditer(r'#\s*include\s*(?:<([^>]+)>|"([^"]+)")', code): + h = m.group(1) or m.group(2) + if h in own: + continue if h in self._BUILTIN_HEADERS: continue # Skip paths with / (esp-idf internal headers like freertos/FreeRTOS.h) @@ -2497,9 +2513,10 @@ class ESPIDFCompiler: # which causes intermittent "cmake configure failed" and stale-object # false positives when a different project/manifest compiles next. _sketch_text = '\n'.join(f.get('content', '') for f in files) + _own_names = {Path(str(f.get('name') or '')).name for f in files} _core_hdrs = self._core_provided_headers() _ext_inc_token = ','.join(sorted( - h for h in set(self._detect_external_includes(_sketch_text)) + h for h in set(self._detect_external_includes(_sketch_text, _own_names)) if h not in _core_hdrs )) @@ -2811,13 +2828,15 @@ class ESPIDFCompiler: # was scanned, so libs only referenced from project headers # never reached _resolve_library_components and the build # died with "fatal error: ESP32Servo.h: No such file". + own_names = {PurePosixPath(str(_f.get('name') or '').replace('\\', '/')).name + for _f in files} ext_headers_set: set[str] = set( - self._detect_external_includes(main_content) + self._detect_external_includes(main_content, own_names) ) for _f in files: if _f.get('name', '').endswith(('.h', '.hpp', '.ino', '.c', '.cpp')): ext_headers_set.update( - self._detect_external_includes(_f.get('content', '')) + self._detect_external_includes(_f.get('content', ''), own_names) ) ext_headers = list(ext_headers_set) component_names: list[str] = [] diff --git a/test/backend/unit/test_espidf_compiler.py b/test/backend/unit/test_espidf_compiler.py index d286725e..7f322221 100644 --- a/test/backend/unit/test_espidf_compiler.py +++ b/test/backend/unit/test_espidf_compiler.py @@ -455,3 +455,48 @@ if __name__ == '__main__': runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) sys.exit(0 if result.wasSuccessful() else 1) + + +class TestDetectExternalIncludesQuotedForm(unittest.TestCase): + """Both include forms must reach the library resolver. + + Arduino treats `#include "Lib.h"` and `#include ` alike for + libraries, and vendors' own examples use the quoted form — M5Stack's + Cardputer examples open with `#include "M5Cardputer.h"`. Scanning only the + angled form meant those sketches never reached the resolver and died on + `fatal error: M5Cardputer.h: No such file`, while the same sketch with angle + brackets built fine. + """ + + def setUp(self): + self.comp = make_compiler() + + def test_angled_include_is_detected(self): + self.assertIn('M5Cardputer.h', + self.comp._detect_external_includes('#include ')) + + def test_quoted_include_is_detected(self): + self.assertIn('M5Cardputer.h', + self.comp._detect_external_includes('#include "M5Cardputer.h"')) + + def test_both_forms_in_one_sketch(self): + code = '#include "M5Cardputer.h"\n#include \n' + found = self.comp._detect_external_includes(code) + self.assertIn('M5Cardputer.h', found) + self.assertIn('M5GFX.h', found) + + def test_a_projects_own_header_is_not_a_library(self): + code = '#include "Common.h"\n#include "M5Cardputer.h"\n' + found = self.comp._detect_external_includes(code, {'Common.h', 'sketch.ino'}) + self.assertNotIn('Common.h', found) + self.assertIn('M5Cardputer.h', found) + + def test_idf_internal_headers_are_still_skipped(self): + code = '#include "freertos/FreeRTOS.h"\n#include "esp_wifi.h"\n' + self.assertEqual(self.comp._detect_external_includes(code), []) + + def test_spacing_variants(self): + for code in ('#include"M5Cardputer.h"', '# include "M5Cardputer.h"'): + with self.subTest(code=code): + self.assertIn('M5Cardputer.h', + self.comp._detect_external_includes(code))