| #!/usr/bin/env python3 |
| |
| # SPDX-FileCopyrightText: 2026 Basalte bv |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| """ |
| Style checker for Zephyr Kconfig files. |
| |
| Checks the given files against the "Basic Formatting Rules" documented in |
| doc/contribute/style/kconfig.rst: |
| |
| 1. Line length: keep lines to 100 columns or fewer. Lines containing a |
| Kconfig macro expansion '$(...)' are exempt, as such calls cannot be |
| wrapped across lines. |
| 2. Indentation: use a flat layout with tabs - entries at column 0, properties |
| at a single tab, and 'help' entry text at one tab plus two extra spaces. |
| Continuation lines may use any number of tabs but must not mix in spaces. |
| 3. Spacing: leave a single empty line between option declarations. |
| 4. Comments: format comments as '# Comment' rather than '#Comment'. |
| 5. Conditional blocks: insert an empty line before/after each top-level 'if' |
| and 'endif' statement. |
| 6. End of file: end the file with exactly one newline. |
| |
| Any style issues are printed as '<file>:<line>:<col>: [<rule>] <message>'. The |
| exit status distinguishes "found issues" from "the checker failed": |
| |
| 0 (EXIT_OK) - no style issues found |
| 1 (EXIT_ISSUES) - style issues were found and printed to stdout |
| 2 (EXIT_ERROR) - the checker itself failed (e.g. a file could not be read); |
| a message is printed to stderr |
| |
| Keeping these distinct lets callers (such as the CI compliance check) tell a |
| clean "issues found" run apart from a crash, instead of guessing from stderr. |
| """ |
| |
| import argparse |
| import re |
| import sys |
| import traceback |
| from collections.abc import Iterator |
| from dataclasses import dataclass |
| from pathlib import Path |
| |
| EXIT_OK = 0 |
| EXIT_ISSUES = 1 |
| EXIT_ERROR = 2 |
| |
| # A tab is rendered as 8 columns when measuring line length, matching the rest |
| # of the Zephyr coding style. |
| TAB_WIDTH = 8 |
| MAX_COLUMNS = 100 |
| |
| # Help body text is indented with one tab plus two extra spaces. |
| HELP_PREFIX = "\t " |
| |
| # Line-leading keyword matchers. These cover deliberately different keyword sets: |
| # ENTRY_RE - declarations that need a blank line before them (rule 3) |
| # BLOCK_RE - entries and block delimiters that live at column 0 (rule 2), |
| # matched against a line with its leading tabs removed |
| # IF_ENDIF_RE - top-level 'if'/'endif' that need surrounding blanks (rule 5) |
| # OPENERS_RE - block openers after which a declaration needs no blank line |
| ENTRY_RE = re.compile(r"(menuconfig|config|choice|menu|comment|mainmenu)\b") |
| BLOCK_RE = re.compile( |
| r"(menuconfig|config|choice|endchoice|menu|endmenu|if|endif|comment|mainmenu" |
| r"|source|rsource|osource|orsource)\b" |
| ) |
| IF_ENDIF_RE = re.compile(r"(if|endif)\b") |
| OPENERS_RE = re.compile(r"(if|menu|choice|mainmenu)\b") |
| |
| # 'help' (or the legacy '---help---') keyword line, possibly indented with tabs. |
| HELP_RE = re.compile(r"^\t*(help|---help---)\s*$") |
| |
| |
| @dataclass |
| class Issue: |
| """A single style violation, used for reporting.""" |
| |
| line: int # 1-based |
| col: int # 1-based |
| rule: str |
| message: str |
| |
| |
| def _leading_ws(line: str) -> str: |
| """Return the leading whitespace (tabs and spaces) of 'line'.""" |
| return line[: len(line) - len(line.lstrip(" \t"))] |
| |
| |
| def _columns(line: str) -> int: |
| """Return the display width of 'line', expanding tabs.""" |
| return len(line.expandtabs(TAB_WIDTH)) |
| |
| |
| def _continues(line: str) -> bool: |
| """ |
| True if 'line' continues onto the next one via a trailing backslash. The |
| backslash must be the final character: a backslash before trailing |
| whitespace escapes that whitespace rather than continuing the line. |
| """ |
| return line.endswith("\\") |
| |
| |
| def _is_continuation(lines: list[str], i: int) -> bool: |
| """True if line 'i' continues the previous line via a trailing backslash.""" |
| return i > 0 and _continues(lines[i - 1]) |
| |
| |
| def _stmt_start(lines: list[str], i: int) -> int: |
| """ |
| First line index of the statement that line 'i' belongs to, walking back over |
| backslash continuations (the inverse of _stmt_end). |
| """ |
| j = i |
| while j > 0 and _continues(lines[j - 1]): |
| j -= 1 |
| return j |
| |
| |
| def _stmt_end(lines: list[str], i: int) -> int: |
| """ |
| Last line index of the statement starting at line 'i', following trailing |
| backslash continuations (e.g. a multi-line 'if' condition). |
| """ |
| j = i |
| while j < len(lines) - 1 and _continues(lines[j]): |
| j += 1 |
| return j |
| |
| |
| def _comment_start(line: str) -> int: |
| """ |
| Return the index of the '#' that starts a comment on 'line', or -1 if the |
| line has no comment. Ignores '#' characters inside double-quoted strings so |
| that e.g. 'default "#fff"' is not mistaken for a comment. |
| """ |
| if "#" not in line: |
| return -1 |
| |
| in_quote = False |
| escaped = False |
| for i, ch in enumerate(line): |
| if escaped: |
| escaped = False |
| continue |
| if ch == "\\": |
| escaped = True |
| elif ch == '"': |
| in_quote = not in_quote |
| elif ch == "#" and not in_quote: |
| return i |
| return -1 |
| |
| |
| def _iter_help_state(lines: list[str]) -> Iterator[tuple[int, str, bool]]: |
| """ |
| Yield (index, line, in_help) for each line, where 'in_help' is True for |
| lines that are part of a 'help' entry's body text. |
| """ |
| in_help = False |
| help_kw_cols = 0 |
| for i, line in enumerate(lines): |
| if in_help: |
| if line.strip() == "": |
| # Blank lines stay inside the help block. |
| yield i, line, True |
| continue |
| if _columns(_leading_ws(line)) > help_kw_cols: |
| yield i, line, True |
| continue |
| # Indentation dropped back: the help block has ended. |
| in_help = False |
| |
| if HELP_RE.match(line): |
| in_help = True |
| help_kw_cols = _columns(_leading_ws(line)) |
| yield i, line, False |
| continue |
| |
| yield i, line, False |
| |
| |
| def _indent_issue(lines: list[str], i: int, line: str, in_help: bool) -> Issue | None: |
| """ |
| Rule 2: check the indentation of a single line. Zephyr uses a flat layout: |
| entries at column 0, properties at a single tab, and help text at one tab |
| plus two extra spaces. |
| """ |
| if not line.strip(): |
| return None |
| |
| lineno = i + 1 |
| if in_help: |
| if not line.startswith(HELP_PREFIX) or "\t" in line[len(HELP_PREFIX) :].lstrip(" "): |
| return Issue( |
| lineno, |
| 1, |
| "help-indent", |
| "help text must be indented with one tab plus two extra spaces", |
| ) |
| return None |
| |
| if line.startswith(" "): |
| # Indentation must start with a tab. |
| return Issue(lineno, 1, "tab-indent", "use tabs for indentation, not spaces") |
| |
| if _is_continuation(lines, i): |
| # Continuation lines may use any number of tabs to align with the |
| # statement they continue, but must not mix in spaces (a space after a |
| # tab is injected verbatim into the joined line by kconfiglib). |
| if " " in _leading_ws(line): |
| return Issue( |
| lineno, 1, "cont-indent", "use tabs only on continuation lines, not spaces" |
| ) |
| return None |
| |
| content = line.lstrip("\t") |
| tabs = len(line) - len(content) |
| if BLOCK_RE.match(content): |
| if tabs: |
| return Issue(lineno, 1, "over-indent", "Kconfig entries must start at column 0") |
| elif tabs > 1: |
| return Issue(lineno, 1, "over-indent", "use a single tab to indent properties") |
| return None |
| |
| |
| def lint(lines: list[str]) -> list[Issue]: |
| """Return a list of Issue objects for the given list of lines.""" |
| issues = [] |
| help_body = set() |
| |
| for i, line, in_help in _iter_help_state(lines): |
| lineno = i + 1 |
| if in_help: |
| help_body.add(i) |
| |
| # Rule 1: line length. Lines containing a Kconfig macro expansion |
| # '$(...)' are exempt: such calls cannot be wrapped, as kconfiglib |
| # joins backslash continuations by raw concatenation and the |
| # continuation's leading whitespace would be injected into the macro |
| # arguments, corrupting them. |
| cols = _columns(line) |
| if cols > MAX_COLUMNS and "$(" not in line: |
| issues.append( |
| Issue( |
| lineno, |
| MAX_COLUMNS + 1, |
| "line-too-long", |
| f"line is {cols} columns, over the {MAX_COLUMNS} column limit", |
| ) |
| ) |
| |
| # Rule 2: indentation. |
| if issue := _indent_issue(lines, i, line, in_help): |
| issues.append(issue) |
| |
| # Rule 4: comment spacing (skip help body). A tab counts as separation |
| # too, so commented-out code such as '#<tab>help' is not flagged. |
| if not in_help: |
| c = _comment_start(line) |
| if c != -1: |
| rest = line[c + 1 :] |
| if rest and rest[0] not in (" ", "\t", "#", "\n"): |
| issues.append( |
| Issue( |
| lineno, |
| c + 1, |
| "comment-space", |
| "add a space after '#' in comments", |
| ) |
| ) |
| |
| issues.extend(_lint_blank_lines(lines, help_body)) |
| |
| issues.sort(key=lambda issue: (issue.line, issue.col)) |
| return issues |
| |
| |
| def _is_entry(line: str) -> bool: |
| return ENTRY_RE.match(line) is not None |
| |
| |
| def _if_endif_kw(line: str) -> str | None: |
| """Return 'if'/'endif' if 'line' is such a column-0 statement, else None.""" |
| m = IF_ENDIF_RE.match(line) |
| return m.group(1) if m else None |
| |
| |
| def _is_full_comment(line: str) -> bool: |
| """True if 'line' is a comment line (only whitespace before the '#').""" |
| c = _comment_start(line) |
| return c != -1 and line[:c].strip() == "" |
| |
| |
| def _decl_unit_start(lines: list[str], i: int) -> int: |
| """ |
| Return the first line index of the declaration "unit" ending at entry line |
| 'i', i.e. walk upwards over any comment lines attached directly above the |
| entry (they document it and belong with it). |
| """ |
| j = i |
| while j > 0 and _is_full_comment(lines[j - 1]): |
| j -= 1 |
| return j |
| |
| |
| def _blank_requirements( |
| lines: list[str], help_body: set[int] |
| ) -> tuple[dict[int, tuple[str, str]], dict[int, tuple[str, str]]]: |
| """ |
| Return (before, after): maps of line index -> ('rule', 'message') for indices |
| that must be preceded / followed by a blank line (rule 3 declarations and |
| rule 5 top-level if/endif). Computed in a single pass over 'lines'. |
| """ |
| n = len(lines) |
| before: dict[int, tuple[str, str]] = {} |
| after: dict[int, tuple[str, str]] = {} |
| for i, line in enumerate(lines): |
| kw = _if_endif_kw(line) |
| if kw: |
| # A comment block directly above an 'if' documents the conditional |
| # block and belongs with it, so the required blank line goes before |
| # the comment rather than between the comment and the 'if'. |
| start = _decl_unit_start(lines, i) if kw == "if" else i |
| if start > 0: |
| before[start] = ("if-blank", f"add a blank line before '{kw}'") |
| # The blank line goes after the whole statement: an 'if' condition |
| # may span several lines via backslash continuations. |
| end = _stmt_end(lines, i) |
| if end < n - 1: |
| after[end] = ("if-blank", f"add a blank line after '{kw}'") |
| elif i not in help_body and _is_entry(line): |
| start = _decl_unit_start(lines, i) |
| # No blank line is needed right after a block opener. The preceding |
| # line may be a continuation, so test the opener at the start of that |
| # statement, not the (possibly continued) line directly above. |
| if start > 0 and not OPENERS_RE.match(lines[_stmt_start(lines, start - 1)]): |
| before.setdefault(start, ("decl-blank", "add a blank line before this declaration")) |
| return before, after |
| |
| |
| def _lint_blank_lines(lines: list[str], help_body: set[int]) -> list[Issue]: |
| """Issues for rules 3 (single blank between declarations) and 5 (if/endif).""" |
| issues = [] |
| n = len(lines) |
| |
| def blank(idx: int) -> bool: |
| return 0 <= idx < n and lines[idx].strip() == "" |
| |
| # Rule 3: collapse runs of blank lines. Blanks inside help text are not |
| # declaration separators, so they are excluded via 'help_body'. |
| consecutive = 0 |
| for i, line in enumerate(lines): |
| if line.strip() == "" and i not in help_body: |
| consecutive += 1 |
| if consecutive > 1: |
| issues.append(Issue(i + 1, 1, "blank-lines", "remove consecutive blank lines")) |
| else: |
| consecutive = 0 |
| |
| # Rules 3 and 5: required blank lines before/after. |
| before, after = _blank_requirements(lines, help_body) |
| for i, (rule, msg) in before.items(): |
| if not blank(i - 1): |
| issues.append(Issue(i + 1, 1, rule, msg)) |
| for i, (rule, msg) in after.items(): |
| if not blank(i + 1): |
| issues.append(Issue(i + 1, 1, rule, msg)) |
| |
| return issues |
| |
| |
| def _final_newline_issues(raw: str) -> list[Issue]: |
| """Rule 6: a file must end with exactly one newline.""" |
| if not raw: |
| return [] |
| if not raw.endswith("\n"): |
| last = raw.rsplit("\n", 1)[-1] |
| return [ |
| Issue( |
| raw.count("\n") + 1, |
| len(last) + 1, |
| "final-newline", |
| "add a newline at the end of the file", |
| ) |
| ] |
| if raw.endswith("\n\n"): |
| return [ |
| Issue( |
| len(raw.rstrip("\n").split("\n")) + 1, |
| 1, |
| "final-newline", |
| "remove blank line(s) at the end of the file", |
| ) |
| ] |
| return [] |
| |
| |
| def check_file(path: Path) -> list[Issue]: |
| """Return the list of style issues for a single file.""" |
| raw = path.read_text(encoding="utf-8") |
| lines = raw[:-1].split("\n") if raw.endswith("\n") else raw.split("\n") |
| issues = lint(lines) + _final_newline_issues(raw) |
| issues.sort(key=lambda issue: (issue.line, issue.col)) |
| return issues |
| |
| |
| def _is_kconfig_file(name: str) -> bool: |
| return "Kconfig" in name |
| |
| |
| def expand_paths(paths: list[str]) -> list[Path]: |
| """ |
| Expand the given paths into a de-duplicated list of files. Files are used |
| as-is; directories are searched recursively for Kconfig files (sorted). |
| """ |
| files = [] |
| for path in paths: |
| p = Path(path) |
| if p.is_dir(): |
| files.extend( |
| sorted(f for f in p.rglob("*") if f.is_file() and _is_kconfig_file(f.name)) |
| ) |
| else: |
| files.append(p) |
| # Preserve order while dropping duplicates. |
| return list(dict.fromkeys(files)) |
| |
| |
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| description=__doc__, |
| allow_abbrev=False, |
| ) |
| parser.add_argument( |
| "paths", |
| metavar="PATH", |
| nargs="+", |
| help="Kconfig files to check. Directories are searched recursively for Kconfig files.", |
| ) |
| return parser.parse_args() |
| |
| |
| def main() -> int: |
| args = parse_args() |
| |
| total = 0 |
| failed = False |
| for path in expand_paths(args.paths): |
| try: |
| issues = check_file(path) |
| except Exception: |
| # Never let a checker failure masquerade as "no issues" (exit 0) or |
| # "issues found" (exit 1): report it and exit with EXIT_ERROR. |
| print(f"error: failed to check '{path}':", file=sys.stderr) |
| traceback.print_exc() |
| failed = True |
| continue |
| |
| for issue in issues: |
| print(f"{path}:{issue.line}:{issue.col}: [{issue.rule}] {issue.message}") |
| total += 1 |
| |
| if failed: |
| return EXIT_ERROR |
| return EXIT_ISSUES if total else EXIT_OK |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |