diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml new file mode 100644 index 0000000000..5bf1a6073b --- /dev/null +++ b/.github/workflows/static_checks.yml @@ -0,0 +1,123 @@ +name: Static Checks + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#concurrency +# Ensure that only one commit will be running tests at a time on each PR +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + static-checks: + name: Static Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ------------------------------------------------------------------ + # Every header in include/lvgl/ must appear in include/lvgl/lvgl.h + # ------------------------------------------------------------------ + - name: "Check: public API umbrella (include/lvgl/lvgl.h)" + id: public-api + continue-on-error: true + run: python scripts/static_checks/check_headers.py public-api + + # ------------------------------------------------------------------ + # Every header in src/ must appear in src/lvgl_private.h + # ------------------------------------------------------------------ + - name: "Check: private API umbrella (src/lvgl_private.h)" + id: private-api + continue-on-error: true + run: python scripts/static_checks/check_headers.py private-api + + # ------------------------------------------------------------------ + # - All #includes inside must be valid relative paths. + # - Angle-bracket includes must be in the explicit allow-list + # - see `ALLOWED_EXTERNAL_HEADERS` in `scripts/static_checks/check_headers.py` + # - forbidden headers (stdint.h etc.) must use LV_*_INCLUDE macros. + # ------------------------------------------------------------------ + - name: "Check: include path validity + angle-bracket policy" + id: include-paths + continue-on-error: true + run: python scripts/static_checks/check_headers.py include-paths + + # ------------------------------------------------------------------ + # All external headers in the allow-list (see check above), are used + # ------------------------------------------------------------------ + - name: "Check: all headers in allow-list are used" + id: allowed-list + continue-on-error: true + run: python scripts/static_checks/check_headers.py allowed-list-audit + + # ------------------------------------------------------------------ + # No file may include a deprecated header + # ------------------------------------------------------------------ + - name: "Check: no deprecated header includes" + id: deprecated + continue-on-error: true + run: python scripts/static_checks/check_headers.py deprecated + + # ------------------------------------------------------------------ + # Every header file inside `src` must use `lvgl_public.h` to access + # the public API + # ------------------------------------------------------------------ + - name: "Check: no direct public include from source files" + id: direct-public-include + continue-on-error: true + run: python scripts/static_checks/check_headers.py no-direct-public-include + + # ------------------------------------------------------------------ + # Every typedef struct/enum in include/lvgl/lv_types.h must have + # a real definition (with a body) somewhere + # ------------------------------------------------------------------ + - name: "Check: lv_types.h forward declarations have a real definition" + id: lv_types + continue-on-error: true + run: python scripts/static_checks/check_lv_types.py + + # ------------------------------------------------------------------ + # Final gate – fail the job if any step above failed. + # This gives us full output from every check before failing. + # ------------------------------------------------------------------ + - name: "Gate: fail if any check failed" + if: | + steps.public-api.outcome == 'failure' || + steps.private-api.outcome == 'failure' || + steps.include-paths.outcome == 'failure' || + steps.allowed-list.outcome == 'failure' || + steps.direct-public-include.outcome == 'failure' || + steps.deprecated.outcome == 'failure' || + steps.lv_types.outcome == 'failure' + run: | + echo "Policy check results:" + echo "---------------------" + print_result() { + local name="$1" + local outcome="$2" + if [ "$outcome" = "success" ]; then + echo " ✅ $name" + else + echo " ❌ $name" + fi + } + + print_result "public-api" "${{ steps.public-api.outcome }}" + print_result "private-api" "${{ steps.private-api.outcome }}" + print_result "include-paths" "${{ steps.include-paths.outcome }}" + print_result "allowed-list" "${{ steps.allowed-list.outcome }}" + print_result "direct-public-include" "${{ steps.direct-public-include.outcome }}" + print_result "deprecated" "${{ steps.deprecated.outcome }}" + print_result "lv_types" "${{ steps.lv_types.outcome }}" + + echo "---------------------" + echo "One or more policy checks failed. See above for details." + exit 1 diff --git a/docs/src/contributing/coding_guidelines.mdx b/docs/src/contributing/coding_guidelines.mdx new file mode 100644 index 0000000000..d68db1e7a7 --- /dev/null +++ b/docs/src/contributing/coding_guidelines.mdx @@ -0,0 +1,131 @@ +--- +title: Coding Guidelines +description: Code style rules and policies enforced by LVGL's CI pipeline. +--- + +# Coding Guidelines + +This page documents the source-code policies that are enforced automatically on every pull request. +Each rule exists for a concrete reason, the goal is to make LVGL easy to integrate, portable +across toolchains, and maintainable as the codebase grows. + + +All policies below are checked by the **Policy Checks** CI workflow. The workflow runs every check +independently and reports all failures before marking a PR as failing, so you will always see the +full picture in one run. + + +## Include guidelines + +### 1. External dependencies must use angle-bracket notation + +Headers belonging to external libraries and system-provided headers must use angle-bracket notation +rather than quoted paths. + +```c +/* Wrong */ +#include "freertos/FreeRTOS.h" + +/* Correct */ +#include +``` + +Reason: Angle-bracket notation signals that a header is not part of this project and should be +resolved through the compiler's system or configured include search paths. Quoted includes imply a +path relative to the current file, which is semantically incorrect for external dependencies and +can cause resolution to silently find the wrong file. + + +Angle-bracket includes are subject to the allow-list and macro rules described in rule 2 below. + + +--- + +### 2. Angle-bracket includes must use the allow-list or `LV_*` macros + +Angle-bracket includes are only permitted in two situations: + +- The header appears in `ALLOWED_EXTERNAL_HEADERS` in `scripts/static_checks/check_headers.py`. +- The include uses one of the LVGL indirection macros. + +The following standard headers are **forbidden** as direct angle-bracket includes. +Use the corresponding macro instead: + +| Forbidden | Use instead | +|----------------------------|--------------------------------| +| `#include ` | `#include LV_STDINT_INCLUDE` | +| `#include ` | `#include LV_STDDEF_INCLUDE` | +| `#include ` | `#include LV_STDBOOL_INCLUDE` | +| `#include ` | `#include LV_INTTYPES_INCLUDE` | +| `#include ` | `#include LV_LIMITS_INCLUDE` | +| `#include ` | `#include LV_STDARG_INCLUDE` | + +Reason: LVGL targets a wide range of embedded toolchains, including bare-metal environments +without a standard C library, or environments where these headers live in non-standard locations. +The `LV_*_INCLUDE` macros can be overridden by the user to point to platform-specific +replacements, giving integrators full control. + +The allow-list also serves a documentation purpose: every external dependency is explicitly +enumerated in one place, making it easy to audit what LVGL pulls in from the outside and to +assess the cost of porting to a new platform. + + +To add a new allowed angle-bracket include, add it to `ALLOWED_EXTERNAL_HEADERS` in +`scripts/static_checks/check_headers.py` with a comment explaining why it is needed. + + +--- + +### 3. All `#include` directives inside `src/` and `include/` must use relative paths + +Source files inside `src/` must use quoted, relative includes, e.g. `"../core/lv_obj_private.h"`. + +Reason: LVGL supports build environments, such as Arduino, where the build system does not +allow specifying additional include search directories. Relative paths work everywhere because they +are resolved by the preprocessor relative to the including file, with no reliance on `-I` flags or +toolchain configuration. + +--- + +### 4. Internal code must not include public headers directly + +Source files under `src/` must not include headers from `include/lvgl/` by path. Instead, they +must obtain the full public API through `lvgl_public.h`, which itself pulls in `lvgl.h`. + +```c +/* Wrong, do not do this */ +#include "../../include/lvgl/lv_obj.h" + +/* Correct */ +#include "../lvgl_public.h" +``` + +Reason: Source files under `src/` must not reach into `include/lvgl/` directly because the +relative paths become deeply nested and fragile as the directory structure evolves. More +importantly, `lvgl_public.h` serves as an indirection point that adapts to the installation +context: during development it resolves to `../include/lvgl/lvgl.h`, but once the library is +installed it redirects to the system-installed `../lvgl/lvgl.h`. Bypassing it by including public +headers directly would break this mechanism and cause include resolution to fail in installed +builds. + +--- + +### 5. Every public header must be reachable from `lvgl.h` + +Every `.h` file under `include/lvgl/` must be directly included by `include/lvgl/lvgl.h`. + +Reason: Users integrating LVGL are not required to use the umbrella header, but if they do, +`#include "lvgl.h"` should be enough to pull in the entire public API. Internally, LVGL's own +source code also uses `lvgl.h` as the single authoritative entry point for the public API. This +keeps maintenance simple and avoids accidental omissions. + +--- + +### 6. Every private header must be reachable from `lvgl_private.h` + +Every `.h` file under `src/` must be directly included by `src/lvgl_private.h`. + +Reason: The same reasoning applies internally: `lvgl_private.h` is the single entry point for +LVGL's private API. Any internal translation unit that needs access to internal types or +functions includes this one header and gets everything. This makes the internal surface area +auditable and prevents headers from silently falling out of the dependency graph. diff --git a/scripts/static_checks/check_headers.py b/scripts/static_checks/check_headers.py new file mode 100755 index 0000000000..42a67014ef --- /dev/null +++ b/scripts/static_checks/check_headers.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python3 +""" +LVGL header consistency checker. + +Subcommands (each exits 0 on success, 1 on failure): + public-api Check every header in include/lvgl/ is included by include/lvgl/lvgl.h + private-api Check every header in src/ is included by src/lvgl_private.h + include-paths Check every #include inside src/ references a valid relative path + (angle-bracket includes must appear in ALLOWED_EXTERNAL_HEADERS) + allowed-list-audit Report usage of every entry in ALLOWED_EXTERNAL_HEADERS across + src/ and include/lvgl/; fail if any entry is unused anywhere + deprecated Check no source file includes a deprecated header + no-direct-public-include + Check src/ never includes include/lvgl/ headers directly +""" + +from __future__ import annotations + +import argparse +import fnmatch +import os +import re +import subprocess +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Colors +# --------------------------------------------------------------------------- + + +def _colors_enabled() -> bool: + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() or os.environ.get("FORCE_COLOR") == "1" + + +class C: + _on = _colors_enabled() + RED = "\033[31m" if _on else "" + YELLOW = "\033[33m" if _on else "" + GREEN = "\033[32m" if _on else "" + BOLD = "\033[1m" if _on else "" + RESET = "\033[0m" if _on else "" + + +def log_ok(msg: str) -> None: + print(f"{C.GREEN}{C.BOLD}OK:{C.RESET} {msg}") + + +def log_fatal(msg: str) -> None: + print(f"{C.RED}{C.BOLD}ERROR:{C.RESET} {C.RED}{msg}{C.RESET}") + + +def _error(location: str, msg: str) -> None: + print(f"{C.RED}{C.BOLD}{location}:{C.RESET} {msg}") + + +def _warn(location: str, msg: str) -> None: + print(f"{C.YELLOW}{C.BOLD}{location}:{C.RESET} {msg}") + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SOURCE_EXTENSIONS = {".c", ".cpp", ".h", ".hpp"} + +DEPRECATION_MARKERS = ( + "#warning Include public headers from the `src` folder is deprecated", + "is no longer part of the public API", +) + +# Angle-bracket includes that are explicitly forbidden. +# Files should instead use the LVGL indirection macros shown below. +# +# Instead of: Use: +# #include #include LV_STDINT_INCLUDE +# #include #include LV_STDDEF_INCLUDE +# #include #include LV_STDBOOL_INCLUDE +# #include #include LV_INTTYPES_INCLUDE +# #include #include LV_LIMITS_INCLUDE +# #include #include LV_STDARG_INCLUDE +# +# Add any other headers that should NEVER appear as raw angle-bracket +# includes to FORBIDDEN_SYSTEM_HEADERS. Everything not in this set that +# appears as an angle-bracket include is reported as an error (the +# developer must either add it to ALLOWED_EXTERNAL_HEADERS or switch to a +# quoted / macro include). +# +# To allow an angle-bracket header unconditionally, add it to +# ALLOWED_EXTERNAL_HEADERS instead. + +FORBIDDEN_SYSTEM_HEADERS: set[str] = { + "stdint.h", + "stddef.h", + "stdbool.h", + "inttypes.h", + "limits.h", + "stdarg.h", +} + +# Headers that are explicitly permitted inside <>. +# This is the exhaustive allow-list; anything not here (and not forbidden) +# is still an error so the list stays intentional and auditable. +ALLOWED_EXTERNAL_HEADERS: set[str] = { + "FreeRTOS.h", + "GL/glew.h", + "GLES2/gl2.h", + "GLES3/gl3.h", + "GLFW/glfw3.h", + "LVGL_thread.h", + "LittleFS.h", + "Pre_Include_Global.h", + "SD.h", + "SDL2/SDL.h", + "SDL2/SDL_syswm.h", + "SPI.h", + "TFT_eSPI.h", + "X11/Xlib.h", + "X11/Xutil.h", + "__arm_2d_impl.h", + "algorithm", + "arm_2d.h", + "arm_neon.h", + "assert.h", + "atomic.h", + "bsp_api.h", + "cmsis_os2.h", + "cstdint", + "ctype.h", + "dave_driver.h", + "debug.h", + "dev/evdev/input.h", + "direct.h", + "dirent.h", + "dlfcn.h", + "driver/ppa.h", + "drm/drm_fourcc.h", + "drm_fourcc.h", + "errno.h", + "esp_cache.h", + "esp_err.h", + "esp_heap_caps.h", + "esp_log.h", + "fastgltf/core.hpp", + "fastgltf/math.hpp", + "fastgltf/tools.hpp", + "fastgltf/types.hpp", + "fastgltf/util.hpp", + "fcntl.h", + "ff.h", + "float.h", + "freertos/FreeRTOS.h", + "freertos/atomic.h", + "freertos/semphr.h", + "freertos/task.h", + "freetype/config/ftheader.h", + "freetype/fterrors.h", + "freetype/ftsystem.h", + "freetype/fttypes.h", + "freetype/internal/ftdebug.h", + "freetype/internal/ftstream.h", + "fsl_cache.h", + "fsl_elcdif.h", + "fsl_pxp.h", + "fsl_video_common.h", + "ft2build.h", + "functional", + "g2d.h", + "g2dExt.h", + "gbm.h", + "glib.h", + "gst/gst.h", + "gst/gstelementfactory.h", + "gst/video/video.h", + "hal/color_hal.h", + "hal_data.h", + "include/lv_mp_mem_custom_include.h", + "intrin.h", + "jpegint.h", + "jpeglib.h", + "lfs.h", + "libavcodec/avcodec.h", + "libavformat/avformat.h", + "libavutil/imgutils.h", + "libavutil/samplefmt.h", + "libavutil/timestamp.h", + "libinput.h", + "libswscale/swscale.h", + "libyuv/convert_argb.h", + "linux/dma-buf.h", + "linux/fb.h", + "linux/input-event-codes.h", + "linux/input.h", + "linux/limits.h", + "lv_conf_cmsis.h", + "lvgl_support.h", + "lz4.h", + "main.h", + "malloc.h", + "map", + "math.h", + "mqx.h", + "mutex.h", + "nuttx/arch.h", + "nuttx/cache.h", + "nuttx/clock.h", + "nuttx/input/mouse.h", + "nuttx/input/touchscreen.h", + "nuttx/lcd/lcd_dev.h", + "nuttx/mm/mm.h", + "nuttx/tls.h", + "nuttx/video/fb.h", + "platform.h", + "png.h", + "poll.h", + "ppc_ghs.h", + "ppcintrinsics.h", + "process.h", + "pthread.h", + "r_glcdc_rx_if.h", + "r_glcdc_rx_pinset.h", + "riscv_vector.h", + "rlottie_capi.h", + "rtthread.h", + "screen/screen.h", + "sdkconfig.h", + "semaphore.h", + "semphr.h", + "setjmp.h", + "stdio.h", + "stdlib.h", + "string", + "string.h", + "sys/consio.h", + "sys/fbio.h", + "sys/fcntl.h", + "sys/inotify.h", + "sys/ioctl.h", + "sys/keycodes.h", + "sys/mman.h", + "sys/param.h", + "sys/poll.h", + "sys/stat.h", + "sys/syscall.h", + "sys/types.h", + "syslog.h", + "task.h", + "thorvg_capi.h", + "thread", + "time.h", + "unistd.h", + "uv.h", + "vector", + "vg_lite.h", + "wayland-client-protocol.h", + "wayland-client.h", + "wayland-cursor.h", + "wayland-egl.h", + "wayland_linux_dmabuf.h", + "wayland_xdg_shell.h", + "webp/decode.h", + "windows.h", + "windowsx.h", + "xf86drm.h", + "xf86drmMode.h", + "xkbcommon/xkbcommon.h", + "zephyr/irq.h", + "zephyr/kernel.h", +} + +HEADER_CHECK_WHITELIST: set[str] = { + "debugging/vg_lite_tvg", + "drivers/opengles/assets/lv_opengles_shader.c", + "drivers/opengles/opengl_shader/lv_opengl_shader_manager.c", + "drivers/opengles/glad", + "font/lv_font_dejavu_16_persian_hebrew.c", + "font/lv_font_montserrat_10.c", + "font/lv_font_montserrat_12.c", + "font/lv_font_montserrat_12_subpx.c", + "font/lv_font_montserrat_14.c", + "font/lv_font_montserrat_14_aligned.c", + "font/lv_font_montserrat_16.c", + "font/lv_font_montserrat_18.c", + "font/lv_font_montserrat_20.c", + "font/lv_font_montserrat_22.c", + "font/lv_font_montserrat_24.c", + "font/lv_font_montserrat_26.c", + "font/lv_font_montserrat_28.c", + "font/lv_font_montserrat_28_compressed.c", + "font/lv_font_montserrat_30.c", + "font/lv_font_montserrat_32.c", + "font/lv_font_montserrat_34.c", + "font/lv_font_montserrat_36.c", + "font/lv_font_montserrat_38.c", + "font/lv_font_montserrat_40.c", + "font/lv_font_montserrat_42.c", + "font/lv_font_montserrat_44.c", + "font/lv_font_montserrat_46.c", + "font/lv_font_montserrat_48.c", + "font/lv_font_montserrat_8.c", + "font/lv_font_source_han_sans_sc_14_cjk.c", + "font/lv_font_source_han_sans_sc_16_cjk.c", + "font/lv_font_unscii_16.c", + "font/lv_font_unscii_8.c", + "libs/FT800-FT813", + "libs/thorvg", + "libs/vg_lite_driver", + "libs/barcode/code128.h", + "libs/tiny_ttf/stb_truetype_htcw.h", + "libs/tiny_ttf/stb_rect_pack.h", + "libs/nanovg", + "libs/lz4", + "libs/lodepng/lodepng.h", + "libs/lodepng/lodepng.c", + "libs/gltf/stb_image/stb_image.h", + "libs/gltf/gltf_view/assets/lv_gltf_view_shader.c", +} +# Regex helpers +INCLUDE_ANGLE_RE = re.compile(r"#\s*include\s*<([^>]+)>") +INCLUDE_QUOTED_RE = re.compile(r'#\s*include\s*"([^"]+)"') +INCLUDE_MACRO_RE = re.compile(r"#\s*include\s+LV_\w+_INCLUDE") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _source_files(directory: Path): + """Yield all source/header files under *directory*.""" + for path in sorted(directory.rglob("*")): + if path.suffix in SOURCE_EXTENSIONS: + yield path + + +def _read_lines(path: Path) -> list[str]: + try: + return path.read_text(encoding="utf-8", errors="ignore").splitlines() + except OSError: + return [] + + +def _collect_includes(umbrella: Path) -> set[str]: + """ + Return the set of header filenames directly included by *umbrella*. + Single-level only -- the umbrella is expected to include everything directly. + """ + includes: set[str] = set() + for line in _read_lines(umbrella): + m = INCLUDE_QUOTED_RE.search(line) or INCLUDE_ANGLE_RE.search(line) + if m: + includes.add(Path(m.group(1)).name) + return includes + + +def _grep(pattern: str, directory: Path) -> list[tuple[Path, int, str]]: + """ + Fast search using the system grep binary with a pure-Python fallback. + Returns [(path, lineno, line), ...] for every matching line. + """ + try: + result = subprocess.run( + [ + "grep", + "-rn", + "--include=*.h", + "--include=*.c", + "--include=*.cpp", + "--include=*.hpp", + "-E", + pattern, + str(directory), + ], + capture_output=True, + text=True, + ) + hits: list[tuple[Path, int, str]] = [] + for raw in result.stdout.splitlines(): + # grep output format: path:lineno:line + parts = raw.split(":", 2) + if len(parts) == 3: + try: + hits.append((Path(parts[0]), int(parts[1]), parts[2])) + except ValueError: + pass + return hits + except FileNotFoundError: + # grep not available -- fall back to pure Python + compiled = re.compile(pattern) + hits = [] + for path in _source_files(directory): + for lineno, line in enumerate(_read_lines(path), start=1): + if compiled.search(line): + hits.append((path, lineno, line)) + return hits + + +def _find_deprecated_headers(folder: Path) -> set[Path]: + deprecated: set[Path] = set() + for path in folder.rglob("*.h"): + try: + content = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if any(marker in content for marker in DEPRECATION_MARKERS): + # Store the path relative to the folder so it matches include directives + deprecated.add(path.relative_to(folder)) + return deprecated + + +# --------------------------------------------------------------------------- +# Check 1 -- public API umbrella +# --------------------------------------------------------------------------- + + +def check_public_api(repo_root: Path) -> bool: + """Every non-deprecated .h under include/lvgl/ must be included by include/lvgl/lvgl.h.""" + include_dir = repo_root / "include" / "lvgl" + umbrella = include_dir / "lvgl.h" + + if not umbrella.exists(): + log_fatal(f"umbrella header not found: {umbrella}") + return False + + covered = _collect_includes(umbrella) + ok = True + + for header in sorted(include_dir.rglob("*.h")): + if header == umbrella: + continue + if header.name not in covered: + _error( + str(header.relative_to(include_dir)), + "not included by include/lvgl/lvgl.h", + ) + ok = False + + if ok: + log_ok("all public headers are included by include/lvgl/lvgl.h") + return ok + + +# --------------------------------------------------------------------------- +# Check 2 -- private API umbrella +# --------------------------------------------------------------------------- + + +def check_private_api(repo_root: Path) -> bool: + """Every non-deprecated .h under src/ must be included by src/lvgl_private.h.""" + src_dir = repo_root / "src" + umbrella = src_dir / "lvgl_private.h" + + if not umbrella.exists(): + log_fatal(f"private umbrella not found: {umbrella}") + return False + + exceptions = {umbrella.name, "lv_templ.h", "lv_objx_templ.h"} + deprecated = [p.name for p in _find_deprecated_headers(repo_root)] + covered = _collect_includes(umbrella) + ok = True + + for header in sorted(src_dir.rglob("lv_*.h")): + if header.name in deprecated or header.name in exceptions: + continue + if header.name not in covered: + _error( + str(header.relative_to(repo_root)), "not included by src/lvgl_private.h" + ) + ok = False + + if ok: + log_ok("all private headers are included by src/lvgl_private.h") + return ok + + +# --------------------------------------------------------------------------- +# Check 3 -- include path validity + angle-bracket policy +# --------------------------------------------------------------------------- +def is_allowed_header(header: str) -> bool: + return any(fnmatch.fnmatch(header, pattern) for pattern in ALLOWED_EXTERNAL_HEADERS) + + +def check_include_paths(repo_root: Path) -> bool: + """ + For every source file under src/: + - Quoted includes must resolve to an existing file. + - Angle-bracket includes must be in ALLOWED_EXTERNAL_HEADERS and must + NOT be in FORBIDDEN_SYSTEM_HEADERS (use LV_*_INCLUDE macros instead). + - Macro-style includes (LV_*_INCLUDE) are always fine. + """ + src_dir = repo_root / "src" + ok = True + disallowed_headers = set() + for path in _source_files(src_dir): + relative = path.relative_to(src_dir) + # Is this specific file in the whitelist ? + if any(relative == Path(entry) for entry in HEADER_CHECK_WHITELIST): + continue + + # Is this file inside a whitelisted folder ? + if not path.name.startswith("lv") and any( + relative == Path(entry) or relative.is_relative_to(entry) + for entry in HEADER_CHECK_WHITELIST + ): + continue + + for lineno, line in enumerate(_read_lines(path), start=1): + if INCLUDE_MACRO_RE.search(line): + continue + + angle = INCLUDE_ANGLE_RE.search(line) + if angle: + header_name = angle.group(1) + loc = f"{path.relative_to(repo_root)}:{lineno}" + if header_name in FORBIDDEN_SYSTEM_HEADERS: + _error( + loc, + f"forbidden angle-bracket include <{angle.group(1)}>. " + "Use the LV_*_INCLUDE macro instead.", + ) + ok = False + elif not is_allowed_header(header_name): + disallowed_headers.add(header_name) + _error( + loc, + f"unlisted angle-bracket include <{angle.group(1)}>. '{header_name}' " + "Add to ALLOWED_EXTERNAL_HEADERS if intentional.", + ) + ok = False + continue + + quoted = INCLUDE_QUOTED_RE.search(line) + if quoted: + include_path = quoted.group(1) + resolved = (path.parent / include_path).resolve() + if not resolved.exists(): + _error( + f"{path.relative_to(repo_root)}:{lineno}", + f'missing quoted include "{include_path}" ' + f"(resolved to {resolved})", + ) + ok = False + + disallowed_headers = list(disallowed_headers) + disallowed_headers.sort() + for header in disallowed_headers: + print(f"'{header}',") + if ok: + log_ok("all include paths in src/ are valid") + return ok + + +# --------------------------------------------------------------------------- +# Check 4 -- deprecated headers +# --------------------------------------------------------------------------- + + +def check_deprecated(repo_root: Path) -> bool: + """No source file anywhere in the repo may include a deprecated header.""" + src_dir = repo_root / "src" + deprecated = _find_deprecated_headers(repo_root) + + if not deprecated: + log_ok("no deprecated headers found in repo") + return True + + include_re = re.compile(r'#\s*include\s*[<"]([^>"]+)[>"]') + offenders: dict[Path, list[tuple[int, str]]] = {} + + for path in _source_files(repo_root): + hits = [] + for lineno, line in enumerate(_read_lines(path), start=1): + m = include_re.search(line) + if m: + included_path = Path(m.group(1)) + # Match against full relative paths, not just filenames + if included_path in deprecated: + hits.append((lineno, line.strip())) + if hits: + offenders[path] = hits + + if not offenders: + log_ok("no files include deprecated headers") + return True + + for path, hits in sorted(offenders.items()): + for lineno, line in hits: + _error( + f"{path.relative_to(repo_root)}:{lineno}", f"deprecated include: {line}" + ) + return False + + +# --------------------------------------------------------------------------- +# Check 5 -- internal code must not include include/lvgl/* directly +# --------------------------------------------------------------------------- + + +def check_no_direct_public_include(repo_root: Path) -> bool: + """ + No file under src/ may include a header from include/lvgl/ directly. + Internal code must go through lvgl_private.h which pulls in lvgl.h. + """ + src_dir = repo_root / "src" + include_dir = (repo_root / "include" / "lvgl").resolve() + ok = True + deprecated = [p.name for p in _find_deprecated_headers(src_dir)] + + for path in _source_files(src_dir): + for lineno, line in enumerate(_read_lines(path), start=1): + quoted = INCLUDE_QUOTED_RE.search(line) + if not quoted: + continue + resolved = (path.parent / quoted.group(1)).resolve() + if path.name == "lvgl_public.h": + continue + if resolved.is_relative_to(include_dir) and path.name not in deprecated: + _error( + f"{path.relative_to(repo_root)}:{lineno}", + f'direct public include "{quoted.group(1)}". ' + "Internal code must include via lvgl_public.h, not include/lvgl/ directly.", + ) + ok = False + + if ok: + log_ok("no src/ file includes include/lvgl/ directly") + return ok + + +# --------------------------------------------------------------------------- +# Check 6 -- allowed-list audit +# --------------------------------------------------------------------------- + + +def check_allowed_list_audit(repo_root: Path) -> bool: + """ + For every header in ALLOWED_EXTERNAL_HEADERS, search for literal + #include
in both src/ and include/lvgl/. + + Report: + green -- used, all usages are in src/ + yellow -- used, but at least one usage is in include/lvgl/ (leaks to users) + red -- never used anywhere (should be removed from the allow-list) + + Fails if any header is unused. + """ + if not ALLOWED_EXTERNAL_HEADERS: + log_ok("ALLOWED_EXTERNAL_HEADERS is empty, nothing to audit") + return True + + src_dir = repo_root / "src" + include_dir = repo_root / "include" / "lvgl" + ok = True + + col = max(len(h) for h in ALLOWED_EXTERNAL_HEADERS) + 4 # padding for
+ + print(f"\n{C.BOLD}Allowed system header usage report:{C.RESET}\n") + + for header in sorted(ALLOWED_EXTERNAL_HEADERS): + pattern = rf"#\s*include\s*<{re.escape(header)}>" + src_hits = _grep(pattern, src_dir) + inc_hits = _grep(pattern, include_dir) + + padded = f"<{header}>".ljust(col) + + if not src_hits and not inc_hits: + print( + f" {C.RED}{C.BOLD}{padded}{C.RESET} " + f"{C.RED}unused -- remove from ALLOWED_EXTERNAL_HEADERS{C.RESET}" + ) + ok = False + + elif inc_hits: + print( + f" {C.YELLOW}{C.BOLD}{padded}{C.RESET} " + f"{C.YELLOW}leaks into public headers " + f"({len(src_hits)} src, {len(inc_hits)} include){C.RESET}" + ) + for path, lineno, line in sorted(inc_hits): + _warn(f" {path.relative_to(repo_root)}:{lineno}", line.strip()) + + else: + print( + f" {C.GREEN}{padded}{C.RESET} " f"used in {len(src_hits)} src file(s)" + ) + + print() + if ok: + log_ok("all allowed system headers are in use") + return ok + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +CHECKS = { + "public-api": check_public_api, + "private-api": check_private_api, + "include-paths": check_include_paths, + "allowed-list-audit": check_allowed_list_audit, + "deprecated": check_deprecated, + "no-direct-public-include": check_no_direct_public_include, +} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="LVGL header consistency checker", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Subcommands:\n" + "\n".join(f" {k}" for k in CHECKS), + ) + parser.add_argument( + "check", + choices=list(CHECKS), + help="Which check to run", + ) + parser.add_argument( + "--root", + default=".", + help="Repository root (default: current directory)", + ) + args = parser.parse_args() + + repo_root = Path(args.root).resolve() + passed = CHECKS[args.check](repo_root) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/static_checks/check_lv_types.py b/scripts/static_checks/check_lv_types.py new file mode 100755 index 0000000000..1a3964d6e2 --- /dev/null +++ b/scripts/static_checks/check_lv_types.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Check that every forward-declared struct/enum in include/lvgl/lv_types.h +has a real definition (with a body) somewhere inside src/. + +Rules: + - Only `typedef struct _Tag` and `typedef enum _Tag` entries are checked. + - A definition is a line containing `struct _Tag {` or `enum _Tag {`. + - The `{` must be on the same line as the tag name. + - lv_types.h itself is never used as evidence of a definition. + +Exit codes: + 0 – all forward declarations have a matching definition + 1 – one or more forward declarations are dangling +""" + +from __future__ import annotations + +import subprocess +import itertools +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + +# Matches: +# typedef struct _lv_obj_t lv_obj_t; +# typedef enum _lv_align_t lv_align_t; +FORWARD_DECL_RE = re.compile( + r"^\s*typedef\s+(struct|enum)\s+(\S+)\s+\S+\s*;", + re.MULTILINE, +) + + +def parse_forward_declarations(lv_types_h: Path) -> list[tuple[str, str]]: + """ + Return [(kind, tag), ...] for every typedef struct/enum in lv_types.h. + kind is 'struct' or 'enum', tag is the backing name e.g. '_lv_obj_t'. + """ + content = lv_types_h.read_text(encoding="utf-8", errors="ignore") + return [ + (m.group(1), m.group(2)) + for m in FORWARD_DECL_RE.finditer(content) + ] + + +# --------------------------------------------------------------------------- +# Searching +# --------------------------------------------------------------------------- + +SOURCE_EXTENSIONS = {".c", ".cpp", ".h", ".hpp"} + + +def find_definitions_slow(repo_root: Path, kind: str, tag: str) -> list[Path]: + """ + Return all files under src_dir that contain a line like: + struct _lv_obj_t { + enum _lv_align_t { + (whitespace between tag and { is allowed, but both must be on one line) + """ + + src_dir = repo_root / "src" + include_dir = repo_root / "include" / "lvgl" + # e.g. struct\s+_lv_obj_t\s*\{ + pattern = re.compile( + rf"\b{re.escape(kind)}\s+{re.escape(tag)}\s*\{{" + ) + found = [] + + src_source_files = src_dir.rglob("*") + include_headers = include_dir.rglob("*") + + for path in sorted(itertools.chain(src_source_files, include_headers)): + if path.suffix not in SOURCE_EXTENSIONS: + continue + try: + content = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if pattern.search(content): + found.append(path) + return found + +def find_definitions(repo_root: Path, kind: str, tag: str) -> list[Path]: + # Construct the search pattern for grep + # \b ensures we match word boundaries + pattern = rf"\b{kind}\s+{tag}\s*\{{" + + # Define the search paths + search_paths = [ + str(repo_root / "src"), + str(repo_root / "include" / "lvgl") + ] + + # Build the grep command: + # -r: recursive + # -l: only print names of files with matches + # -E: use extended regular expressions + # --include: filter for specific extensions + cmd = ["grep", "-rlE", pattern] + for ext in SOURCE_EXTENSIONS: + # Converts ".h" to "*.h" for grep + cmd.append(f"--include=*{ext}") + + cmd.extend(search_paths) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False + ) + + if result.stdout: + # Split lines and convert back to Path objects + return [Path(p) for p in result.stdout.splitlines()] + except FileNotFoundError: + # Fallback or error if grep isn't installed (e.g., on Windows) + print("Grep not found. Falling back to slow search.") + return find_definitions_slow(repo_root, kind, tag) + + return [] + + +# --------------------------------------------------------------------------- +# Main check +# --------------------------------------------------------------------------- + +def check_forward_declarations(repo_root: Path) -> bool: + lv_types_h = repo_root / "include" / "lvgl" / "lv_types.h" + + if not lv_types_h.exists(): + print(f"ERROR: {lv_types_h} not found") + return False + + declarations = parse_forward_declarations(lv_types_h) + + if not declarations: + print("WARNING: no typedef struct/enum entries found in lv_types.h") + return True + + print(f"Found {len(declarations)} forward declarations in lv_types.h") + + dangling: list[tuple[str, str]] = [] + + for kind, tag in declarations: + print(f"{kind} {tag} ->", end= " ") + hits = find_definitions(repo_root, kind, tag) + if not hits: + print(f"KO") + dangling.append((kind, tag)) + else: + for h in hits: + print(f"{h.relative_to(repo_root)} OK") + + if dangling: + print( + f"DANGLING forward declarations in lv_types.h " + f"({len(dangling)} of {len(declarations)}):\n" + ) + for kind, tag in dangling: + print(f" {kind} {tag}") + return False + + print( + f"OK: all {len(declarations)} forward declarations in lv_types.h " + f"have a matching definition" + ) + return True + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser( + description="Check lv_types.h forward declarations against src/ definitions" + ) + parser.add_argument( + "--root", + default=".", + help="Repository root (default: current directory)", + ) + args = parser.parse_args() + + repo_root = Path(args.root).resolve() + passed = check_forward_declarations(repo_root) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main()