blob: 74d5f82e79c991fc397fe07a923daeb49024bbff [file] [edit]
#!/usr/bin/env python3
"""
LLVM and Subproject CMake Formatter Utility
Token-stream formatter with schema-aware keyword handling, based on the
`cmake-language(7)` EBNF grammar.
Architecture Overview:
1. Lexer (tokenize): Converts raw CMake text into a flat list of typed Token
objects according to the cmake-language(7) EBNF. Handles bracket comments,
bracket arguments, quoted arguments, whitespace, newlines, and parens.
2. Schema Scanner (scan_dynamic_schemas): Walks the token stream to learn
custom keyword schemas from cmake_parse_arguments() calls and
set(*_ARGS ...) variable conventions, populating a FormatterContext.
3. Formatter (format_cmake_content): Iterates the token stream line-by-line,
applying indentation, keyword casing, comment buffering, and multi-line
argument layout rules using the populated FormatterContext.
Minimum Python Version: 3.12 (uses pathlib.Path.walk(), added in 3.12).
Grammar (from cmake-language(7)):
file ::= file_element*
file_element ::= command_invocation line_ending | (bracket_comment|space)* line_ending
line_ending ::= line_comment? newline
command_invocation ::= space* identifier space* '(' arguments ')'
arguments ::= argument? separated_arguments*
separated_arguments ::= separation+ argument? | separation* '(' arguments ')'
argument ::= bracket_argument | quoted_argument | unquoted_argument
Keyword Schema Classification:
1. Options / Flags (0 values): e.g. `OUTPUT_STRIP_TRAILING_WHITESPACE`, `EXCLUDE_FROM_ALL`, `POST_BUILD`, `PARENT_SCOPE`, `FORCE`, `PARSE_ARGV`, `PARSE_ARGN`.
- Option keywords consume 0 arguments and close immediately unless inside an active multi-value list keyword scope.
2. Single-Value Keywords (1 value): e.g. `RESULT_VARIABLE`, `OUTPUT_VARIABLE`, `TARGET`, `WORKING_DIRECTORY`, `ALIAS`, `SUITE`, `CACHE`, `DEPFILE`.
- After value is consumed, keyword closes and subsequent keywords/values align at top-level keyword indent (+2 spaces).
3. Multi-Value List Keywords (1+ values): e.g. `SRCS`, `HDRS`, `DEPENDS`, `FULL_BUILD_DEPENDS`, `COMPILE_OPTIONS`, `LINK_LIBRARIES`, `LOADER_ARGS`, `ARGS`, `ENV`, `PROPERTIES`, `OBJECT`, `STATIC`, `SHARED`, `MODULE`.
- Keywords on the command header line (`cmd(KEYWORD...`) do NOT grant extra nesting to child lines (+2 spaces relative to call base).
- Keywords on their own separate line (`\n KEYWORD...`) indent child list items +4 spaces (+2 relative to keyword line).
4. Dynamic Schema Learning:
- Automatically parses `cmake_parse_arguments(...)` calls in `function(...)` / `macro(...)` AST blocks.
- Automatically learns custom option, single-value, and multi-value argument lists from `set(...)` calls using standard naming conventions (`*_OPTION_ARGS`, `*_SINGLE_VALUE_ARGS`, `*_MULTI_VALUE_ARGS`).
Formatting Rules Enforced:
1. Command Casing: Built-in language commands cased in lowercase (`add_library`, `set`, `if`); module commands (like `ExternalProject_Add`) and custom functions retain canonical/declared casing.
2. Parenthesis Spacing: No space between command name and opening `(`. Collapses multiple spaces between arguments down to a single space.
3. Quoted String Immutability: Quoted arguments (`"..."`) and bracket arguments (`[=[...]=]`) are single immutable AST tokens. Multi-line quoted strings are preserved 100% untouched.
4. Empty Closures: `endif()`, `else()`, `endfunction()`, `endmacro()`, `endforeach()`, `endwhile()`. (Legacy CMake permitted repeating condition/block names in closing commands; modern CMake standardizes on empty parentheses).
5. Schema-Aware Keyword Casing: Keywords in command schema upper-cased; positional args, function parameters & file paths untouched.
6. Multi-line Argument Layout: Keywords and positional args indented +2 spaces relative to call base; multi-value list items indented +4 spaces; closing `)` at +0 spaces.
7. Control Block Indentation: 2-space indentation inside `if`/`foreach`/`function`/`macro`.
8. Comment Formatting: Line comments buffer and align with the indentation level of the code element immediately following them, unless separated by a blank line (standalone comments) or immediately preceding a closing parenthesis `)`.
9. Cleanliness: Trailing whitespace stripped, single trailing newline for non-empty files; empty files preserved 0-byte.
Usage:
cmake_format.py [options] <file|directory>...
Options:
-i, --inplace, --fix Format files in-place.
-n, --dry-run Check formatting without modifying files (Evaluation Mode).
--diff Output unified diffs for files that need formatting.
-j, --jobs N Number of parallel worker processes to use (default: 1).
-h, --help Show this help message.
"""
import sys
import os
import re
import argparse
import difflib
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
import copy
from typing import NamedTuple
class LexError(ValueError):
"""Raised by tokenize() when the input CMake source is malformed.
Attributes:
msg: Human-readable description of the problem.
line: 1-based line number where the unterminated token started.
col: 1-based column number where the unterminated token started.
"""
def __init__(self, msg: str, line: int, col: int) -> None:
super().__init__(f"line {line}, col {col}: {msg}")
self.msg = msg
self.line = line
self.col = col
# Control block commands
CONTROL_START_BLOCKS = {"if", "function", "macro", "foreach", "while"}
CONTROL_MIDDLE_BLOCKS = {"elseif", "else"}
EMPTY_CLOSE_BLOCKS = {
"endif",
"else",
"endfunction",
"endmacro",
"endforeach",
"endwhile",
}
# Target creation commands
TARGET_CREATION_COMMANDS = {"add_library", "add_executable", "add_custom_target"}
# Formatting Constants
INDENT_WIDTH = 2
INDENT_STR = " " * INDENT_WIDTH
# Indent levels relative to a command's base_indent:
# +1 level = keyword line or positional arg line (+2 spaces)
# +2 levels = multi-value list item under a keyword on its own line (+4 spaces)
KEYWORD_INDENT_LEVELS = 1
LIST_ITEM_INDENT_LEVELS = 2
@dataclass
class FormatterContext:
"""Holds learned schema state for formatting operations to ensure isolation and thread-safety."""
learned_options: set[str] = field(default_factory=set)
learned_one_value: set[str] = field(default_factory=lambda: {"ALIAS"})
learned_multi_value: set[str] = field(default_factory=set)
list_keywords: set[str] = field(
default_factory=lambda: {
"SRCS",
"HDRS",
"DEPENDS",
"FULL_BUILD_DEPENDS",
"COMPILE_OPTIONS",
"LINK_LIBRARIES",
"LINK_LIBS",
"FLAGS",
"SOURCES",
"BYPRODUCTS",
"BUILD_BYPRODUCTS",
"CMAKE_ARGS",
"CMAKE_CACHE_ARGS",
"LOADER_ARGS",
"ARGS",
"ENV",
"COMPILE_DEFINITIONS",
"PROPERTIES",
"OBJECT",
"STATIC",
"SHARED",
"MODULE",
}
)
dynamic_schemas: dict[str, "CommandSchema"] = field(default_factory=dict)
# Per-context cache for get_schema_for_cmd() results. Keyed by lowercased
# command name. Invalidated whenever the context is mutated by
# scan_dynamic_schemas(). Excluded from __init__ so it doesn't appear in
# the constructor or backward-compat aliases.
_schema_cache: dict[str, "CommandSchema"] = field(
default_factory=dict, init=False, repr=False, compare=False
)
def clone(self) -> "FormatterContext":
"""Returns a deep copy of this context.
Uses copy.deepcopy() so that any new fields added to FormatterContext
or CommandSchema are automatically included — no lockstep update needed.
"""
return copy.deepcopy(self)
WORKSPACE_CONTEXT = FormatterContext()
def _init_worker(ctx: FormatterContext) -> None:
"""Initializer for ProcessPoolExecutor workers.
Replaces WORKSPACE_CONTEXT in the worker process with the fully
pre-scanned context from the main process. Using an initializer
(rather than relying on fork memory inheritance) ensures the
pre-scanned schemas are available regardless of the multiprocessing
start method ('fork' on Linux, 'spawn' on macOS/Windows).
"""
global WORKSPACE_CONTEXT
WORKSPACE_CONTEXT = ctx
# Pre-compiled regular expressions for Lexer, Schema Scanner, and Formatter.
# Matches uppercase identifier tokens (A-Z, 0-9, _) — used to extract
# keyword names from set(*_ARGS ...) string values.
RE_IDENTIFIER_TOKENS = re.compile(r"[A-Z0-9_]+")
# Matches mixed-case identifiers (a-zA-Z, 0-9, _) — used to extract
# keyword names from cmake_parse_arguments() string arguments, which may
# use any casing.
RE_IDENTIFIER_WORDS = re.compile(r"[a-zA-Z0-9_]+")
RE_OPTION_ARGS = re.compile(
r"set\s*\(\s*([A-Z0-9_]*(?:OPTION|OPTIONAL)_ARGS)\s+([^)]+)\)", re.IGNORECASE
)
RE_SINGLE_VALUE_ARGS = re.compile(
r"set\s*\(\s*([A-Z0-9_]*(?:SINGLE|ONE)_VALUE_ARGS)\s+([^)]+)\)", re.IGNORECASE
)
RE_MULTI_VALUE_ARGS = re.compile(
r"set\s*\(\s*([A-Z0-9_]*(?:MULTI_VALUE|LIST)_ARGS)\s+([^)]+)\)", re.IGNORECASE
)
RE_BRACKET_COMMENT_START = re.compile(r"#\[(=*)\[")
RE_BRACKET_ARG_START = re.compile(r"\[(=*)\[")
RE_COMMENT_HASH = re.compile(r"^(#+)(.*)$")
RE_ARG_FORWARDING_VAR = re.compile(r'^"?\$(?:\{|\()ARG[NV]\d*(?:\}|\))"?$')
RE_COMMAND_INVOCATION = re.compile(r"^(\s*)([a-zA-Z0-9_]+)(\s*)\((.*)$", re.DOTALL)
# Matches the CMake CACHE keyword as a whole word, used to exclude CACHE variable
# declarations from dynamic schema learning.
RE_CACHE = re.compile(r"\bCACHE\b")
@dataclass
class CommandSchema:
"""Represents option, single-value, and multi-value keyword argument schemas for a CMake command."""
options: set[str] = field(default_factory=set)
one_value: set[str] = field(default_factory=set)
multi_value: set[str] = field(default_factory=set)
explicit_keywords: set[str] | None = None
all_keywords: set[str] = field(default_factory=set, init=False)
def __post_init__(self) -> None:
self.options = set(self.options)
self.one_value = set(self.one_value)
self.multi_value = set(self.multi_value)
self.all_keywords = self.options | self.one_value | self.multi_value
if self.explicit_keywords is None:
self.explicit_keywords = set(self.all_keywords)
else:
self.explicit_keywords = set(self.explicit_keywords)
_IF_OPTIONS = {
"NOT",
"AND",
"OR",
"COMMAND",
"POLICY",
"TARGET",
"EXISTS",
"IS_DIRECTORY",
"IS_SYMLINK",
"IS_ABSOLUTE",
"MATCHES",
"LESS",
"GREATER",
"EQUAL",
"LESS_EQUAL",
"GREATER_EQUAL",
"STRLESS",
"STRGREATER",
"STREQUAL",
"STRLESS_EQUAL",
"STRGREATER_EQUAL",
"VERSION_LESS",
"VERSION_GREATER",
"VERSION_EQUAL",
"VERSION_LESS_EQUAL",
"VERSION_GREATER_EQUAL",
"IN_LIST",
"DEFINED",
}
# Official Built-in CMake Command Schema Registry
BUILTIN_COMMAND_SCHEMAS = {
"function": CommandSchema(),
"macro": CommandSchema(),
"foreach": CommandSchema(options={"IN", "LISTS", "ITEMS", "ZIP_LISTS"}),
"while": CommandSchema(),
"if": CommandSchema(options=_IF_OPTIONS),
"elseif": CommandSchema(options=_IF_OPTIONS),
"else": CommandSchema(),
"endif": CommandSchema(),
"endfunction": CommandSchema(),
"endmacro": CommandSchema(),
"endforeach": CommandSchema(),
"endwhile": CommandSchema(),
"cmake_parse_arguments": CommandSchema(options={"PARSE_ARGV", "PARSE_ARGN"}),
"execute_process": CommandSchema(
options={
"OUTPUT_STRIP_TRAILING_WHITESPACE",
"ERROR_STRIP_TRAILING_WHITESPACE",
"OUTPUT_QUIET",
"ERROR_QUIET",
"ECHO_OUTPUT_VARIABLE",
"ECHO_ERROR_VARIABLE",
},
one_value={
"WORKING_DIRECTORY",
"TIMEOUT",
"RESULT_VARIABLE",
"RESULTS_VARIABLE",
"OUTPUT_VARIABLE",
"ERROR_VARIABLE",
"INPUT_FILE",
"OUTPUT_FILE",
"ERROR_FILE",
"ENCODING",
"COMMAND_ERROR_IS_FATAL",
},
multi_value={"COMMAND"},
),
"try_compile": CommandSchema(
options={"GLOBAL_ERROR", "NO_CACHE"},
one_value={"OUTPUT_VARIABLE", "COPY_FILE", "COPY_FILE_ERROR"},
multi_value={
"SOURCES",
"COMPILE_DEFINITIONS",
"LINK_LIBRARIES",
"LINK_OPTIONS",
"CMAKE_FLAGS",
},
),
"add_custom_command": CommandSchema(
options={
"POST_BUILD",
"PRE_BUILD",
"PRE_LINK",
"VERBATIM",
"APPEND",
"USES_TERMINAL",
"COMMAND_EXPAND_LISTS",
},
one_value={
"TARGET",
"MAIN_DEPENDENCY",
"WORKING_DIRECTORY",
"COMMENT",
"DEPFILE",
"JOB_POOL",
"JOB_SERVER_AWARE",
},
multi_value={"COMMAND", "OUTPUT", "BYPRODUCTS", "DEPENDS", "IMPLICIT_DEPENDS"},
),
"add_custom_target": CommandSchema(
options={"ALL", "VERBATIM", "USES_TERMINAL", "COMMAND_EXPAND_LISTS"},
one_value={"WORKING_DIRECTORY", "COMMENT"},
multi_value={"COMMAND", "DEPENDS", "BYPRODUCTS", "SOURCES"},
),
"ExternalProject_Add": CommandSchema(
options={"EXCLUDE_FROM_ALL"},
one_value={
"PREFIX",
"SOURCE_DIR",
"BINARY_DIR",
"INSTALL_DIR",
"DOWNLOAD_COMMAND",
"CONFIGURE_COMMAND",
"BUILD_COMMAND",
"INSTALL_COMMAND",
},
multi_value={
"BUILD_BYPRODUCTS",
"CMAKE_ARGS",
"CMAKE_CACHE_ARGS",
"STEP_TARGETS",
"INDEPENDENT_STEP_TARGETS",
"DEPENDS",
},
),
"find_package": CommandSchema(
options={"EXACT", "QUIET", "REQUIRED", "CONFIG", "NO_MODULE"},
multi_value={"COMPONENTS", "OPTIONAL_COMPONENTS"},
),
"add_library": CommandSchema(
options={"EXCLUDE_FROM_ALL", "GLOBAL", "IMPORTED"},
one_value={"ALIAS"},
multi_value={
"STATIC",
"SHARED",
"MODULE",
"OBJECT",
"PUBLIC",
"PRIVATE",
"INTERFACE",
"SOURCES",
},
),
"add_executable": CommandSchema(
options={"WIN32", "MACOSX_BUNDLE", "EXCLUDE_FROM_ALL", "GLOBAL", "IMPORTED"},
one_value={"ALIAS"},
multi_value={"SOURCES"},
),
"target_link_libraries": CommandSchema(
multi_value={"PUBLIC", "PRIVATE", "INTERFACE", "LINK_PRIVATE", "LINK_PUBLIC"}
),
"target_include_directories": CommandSchema(
options={"BEFORE", "SYSTEM"}, multi_value={"PUBLIC", "PRIVATE", "INTERFACE"}
),
"target_compile_options": CommandSchema(
options={"BEFORE"}, multi_value={"PUBLIC", "PRIVATE", "INTERFACE"}
),
"set_target_properties": CommandSchema(multi_value={"PROPERTIES"}),
"set_source_files_properties": CommandSchema(multi_value={"PROPERTIES"}),
"set_directory_properties": CommandSchema(multi_value={"PROPERTIES"}),
"set_property": CommandSchema(
options={
"GLOBAL",
"DIRECTORY",
"TARGET",
"SOURCE",
"INSTALL",
"TEST",
"CACHE",
"INHERITED",
},
one_value={"PROPERTY"},
multi_value={"APPEND", "APPEND_STRING"},
),
"get_target_property": CommandSchema(),
"get_property": CommandSchema(
options={
"GLOBAL",
"DIRECTORY",
"TARGET",
"SOURCE",
"INSTALL",
"TEST",
"CACHE",
"SET",
"DEFINED",
"BRIEF_DOCS",
"FULL_DOCS",
},
one_value={"PROPERTY"},
),
"list": CommandSchema(
options={
"APPEND",
"PREPEND",
"POP_BACK",
"POP_FRONT",
"REMOVE_AT",
"REMOVE_ITEM",
"REMOVE_DUPLICATES",
"TRANSFORM",
"SORT",
"REVERSE",
"JOIN",
"SUBLIST",
"FILTER",
"FIND",
"GET",
"LENGTH",
"INSERT",
}
),
"set": CommandSchema(options={"PARENT_SCOPE", "FORCE"}, one_value={"CACHE"}),
}
CANONICAL_CMD_CASING = {}
for k in BUILTIN_COMMAND_SCHEMAS.keys():
if k != k.lower():
CANONICAL_CMD_CASING[k.lower()] = k
def get_schema_for_cmd(
cmd_name: str, ctx: FormatterContext | None = None
) -> CommandSchema:
"""Resolves official built-in or dynamically learned argument schema for a given CMake command."""
cmd_lower = cmd_name.lower()
# Standard built-in CMake commands use exact official schemas
if cmd_lower in BUILTIN_COMMAND_SCHEMAS:
return BUILTIN_COMMAND_SCHEMAS[cmd_lower]
context = ctx if ctx is not None else WORKSPACE_CONTEXT
# Return cached schema if available, avoiding repeated set allocations.
cached = context._schema_cache.get(cmd_lower)
if cached is not None:
return cached
options = set(context.learned_options)
one_value_args = set(context.learned_one_value)
multi_value_args = set(context.list_keywords) | set(context.learned_multi_value)
explicit_kws = None
if cmd_lower in context.dynamic_schemas:
ds = context.dynamic_schemas[cmd_lower]
options.update(ds.options)
one_value_args.update(ds.one_value)
multi_value_args.update(ds.multi_value)
explicit_kws = set(ds.explicit_keywords)
# Resolve overlaps so `multi_value` and `one_value` take precedence over `options`.
options -= multi_value_args | one_value_args
one_value_args -= multi_value_args
schema = CommandSchema(
options=options,
one_value=one_value_args,
multi_value=multi_value_args,
explicit_keywords=explicit_kws if explicit_kws is not None else set(),
)
context._schema_cache[cmd_lower] = schema
return schema
class TokenType(str, Enum):
IDENTIFIER = "IDENTIFIER"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
QUOTED_ARG = "QUOTED_ARG"
BRACKET_ARG = "BRACKET_ARG"
UNQUOTED_ARG = "UNQUOTED_ARG"
LINE_COMMENT = "LINE_COMMENT"
BRACKET_COMMENT = "BRACKET_COMMENT"
WHITESPACE = "WHITESPACE"
NEWLINE = "NEWLINE"
class Token(NamedTuple):
"""Represents a single AST token with its TokenType and string payload."""
type: TokenType
value: str
class KeywordType(str, Enum):
ONE_VALUE = "ONE_VALUE"
MULTI_VALUE = "MULTI_VALUE"
OPTION = "OPTION"
def _lex_bracket_span(
text: str,
pos: int,
eq_len: int,
line: int,
col: int,
line_start: int,
token_type: TokenType,
desc: str,
) -> tuple[Token, int, int, int]:
"""Extracts a bracket comment or bracket argument, updating line/col tracking."""
close_pat = "]" + "=" * eq_len + "]"
end_idx = text.find(close_pat, pos)
if end_idx == -1:
prefix = "#" if token_type == TokenType.BRACKET_COMMENT else ""
raise LexError(
f"unterminated {desc} '{prefix}[{'=' * eq_len}['",
line,
col,
)
end_pos = end_idx + len(close_pat)
span = text[pos:end_pos]
newlines = span.count("\n")
if newlines:
line += newlines
line_start = end_pos - len(span.rsplit("\n", 1)[-1])
return Token(token_type, span), end_pos, line, line_start
def tokenize(text: str) -> list[Token]:
"""Formal Lexer based on cmake-language(7) EBNF specification.
Raises:
LexError: If the input contains an unterminated quoted argument,
bracket argument, or bracket comment.
"""
tokens = []
i = 0
n = len(text)
# Track line/col for error reporting. line and col are 1-based.
line = 1
line_start = 0
while i < n:
col = i - line_start + 1
# Bracket comment or line comment starting with '#'
if text[i] == "#":
m = RE_BRACKET_COMMENT_START.match(text, pos=i)
if m:
tok, i, line, line_start = _lex_bracket_span(
text,
i,
len(m.group(1)),
line,
col,
line_start,
TokenType.BRACKET_COMMENT,
"bracket comment",
)
tokens.append(tok)
continue
# Line comment: #...
end_idx = text.find("\n", i)
if end_idx == -1:
end_idx = n
tokens.append(Token(TokenType.LINE_COMMENT, text[i:end_idx]))
i = end_idx
continue
# Bracket argument: [=[...]=]
if text[i] == "[":
m = RE_BRACKET_ARG_START.match(text, pos=i)
if m:
tok, i, line, line_start = _lex_bracket_span(
text,
i,
len(m.group(1)),
line,
col,
line_start,
TokenType.BRACKET_ARG,
"bracket argument",
)
tokens.append(tok)
continue
# Quoted argument: "..." (Single immutable token!)
if text[i] == '"':
j = i + 1
closed = False
while j < n:
if text[j] == "\\":
j = min(n, j + 2)
elif text[j] == '"':
j += 1
closed = True
break
else:
j += 1
if not closed:
raise LexError("unterminated quoted argument", line, col)
token_val = text[i:j]
tokens.append(Token(TokenType.QUOTED_ARG, token_val))
newlines = token_val.count("\n")
if newlines:
line += newlines
line_start = j - len(token_val.rsplit("\n", 1)[-1])
i = j
continue
# Newline
if text[i] == "\n":
tokens.append(Token(TokenType.NEWLINE, "\n"))
i += 1
line += 1
line_start = i
continue
# Whitespace
if text[i] in " \t\r":
j = i
while j < n and text[j] in " \t\r":
j += 1
tokens.append(Token(TokenType.WHITESPACE, text[i:j]))
i = j
continue
# Parens
if text[i] == "(":
tokens.append(Token(TokenType.LPAREN, "("))
i += 1
continue
if text[i] == ")":
tokens.append(Token(TokenType.RPAREN, ")"))
i += 1
continue
# Unquoted argument / Identifier
j = i
while j < n and text[j] not in ' \t\r\n()#"':
if text[j] == "\\":
j = min(n, j + 2)
else:
j += 1
token_val = text[i:j]
tokens.append(Token(TokenType.UNQUOTED_ARG, token_val))
i = j
return tokens
def _next_significant_token(
tokens: list[Token], start_idx: int
) -> tuple[Token, int] | tuple[None, -1]:
"""Finds the next non-whitespace, non-newline, non-comment token from start_idx."""
n = len(tokens)
for idx in range(start_idx, n):
tok = tokens[idx]
if tok.type not in (
TokenType.WHITESPACE,
TokenType.NEWLINE,
TokenType.LINE_COMMENT,
TokenType.BRACKET_COMMENT,
):
return tok, idx
return None, -1
def scan_dynamic_schemas(
content: str,
ctx: FormatterContext | None = None,
tokens: list[Token] | None = None,
) -> None:
"""Scans cmake_parse_arguments and set(..._ARGS) calls to learn keyword schemas dynamically.
Mutates ctx in-place: appends newly discovered keywords to ctx.learned_options,
ctx.learned_one_value, ctx.learned_multi_value, ctx.list_keywords, and
ctx.dynamic_schemas. If ctx is None, mutates the module-level WORKSPACE_CONTEXT.
"""
context = ctx if ctx is not None else WORKSPACE_CONTEXT
context._schema_cache.clear()
# 1. Parse set(*_OPTION_ARGS ...), set(*_SINGLE_VALUE_ARGS ...), set(*_MULTI_VALUE_ARGS ...), etc.
schema_patterns = [
(RE_OPTION_ARGS, context.learned_options),
(RE_SINGLE_VALUE_ARGS, context.learned_one_value),
(RE_MULTI_VALUE_ARGS, context.learned_multi_value),
]
for regex, target_set in schema_patterns:
for m in regex.finditer(content):
if not RE_CACHE.search(m.group(2)):
words = set(RE_IDENTIFIER_TOKENS.findall(m.group(2)))
target_set.update(words)
if target_set is context.learned_multi_value:
context.list_keywords.update(words)
# 2. Token-based scanning for function()/macro() declarations and cmake_parse_arguments
if tokens is None:
tokens = tokenize(content)
n = len(tokens)
current_fn_name = None
for i in range(n):
tok = tokens[i]
if tok.type != TokenType.UNQUOTED_ARG:
continue
cmd_lower = tok.value.lower()
# Track function/macro start
if cmd_lower in ("function", "macro"):
tok_lparen, j = _next_significant_token(tokens, i + 1)
if tok_lparen and tok_lparen.type == TokenType.LPAREN:
tok_name, _ = _next_significant_token(tokens, j + 1)
if tok_name and tok_name.type in (
TokenType.UNQUOTED_ARG,
TokenType.QUOTED_ARG,
):
current_fn_name = tok_name.value.strip('"')
# Track function/macro end
elif cmd_lower in ("endfunction", "endmacro"):
current_fn_name = None
# Parse cmake_parse_arguments(...) call inside function/macro
elif cmd_lower == "cmake_parse_arguments" and current_fn_name:
tok_lparen, lparen_idx = _next_significant_token(tokens, i + 1)
if tok_lparen and tok_lparen.type == TokenType.LPAREN:
arg_tokens = []
paren_depth = 1
for j in range(lparen_idx + 1, n):
tok_j = tokens[j]
if tok_j.type in (
TokenType.WHITESPACE,
TokenType.NEWLINE,
TokenType.LINE_COMMENT,
TokenType.BRACKET_COMMENT,
):
continue
if tok_j.type == TokenType.LPAREN:
paren_depth += 1
elif tok_j.type == TokenType.RPAREN:
paren_depth -= 1
if paren_depth <= 0:
break
else:
arg_tokens.append(tok_j.value)
# cmake_parse_arguments positional layout:
# cmake_parse_arguments(<prefix> <options> <one_value> <multi_value> <args>...)
# cmake_parse_arguments(PARSE_ARGV <n> <prefix> <options> <one_value> <multi_value>)
# When PARSE_ARGV/PARSE_ARGN is present, the first two tokens are the
# mode keyword and the index argument; the prefix/options/... follow at +2.
_CPA_MIN_ARGS = 4 # prefix + options + one_value + multi_value
_CPA_PARSE_ARGV_EXTRA = 2 # extra tokens: mode keyword + integer index
_CPA_PARSE_ARGV_MIN_ARGS = _CPA_MIN_ARGS + _CPA_PARSE_ARGV_EXTRA
if len(arg_tokens) >= _CPA_MIN_ARGS:
offset = 0
if (
arg_tokens[0].upper() in ("PARSE_ARGV", "PARSE_ARGN")
and len(arg_tokens) >= _CPA_PARSE_ARGV_MIN_ARGS
):
offset = _CPA_PARSE_ARGV_EXTRA
if len(arg_tokens) >= offset + _CPA_MIN_ARGS:
# arg_tokens[offset + 0] is the prefix — not needed for schema building
opt_str = arg_tokens[offset + 1]
one_str = arg_tokens[offset + 2]
multi_str = arg_tokens[offset + 3]
def _extract_literal_kws(arg_s: str) -> set[str]:
cleaned = arg_s.strip('"\t\r\n ')
if cleaned.startswith("$"):
return set()
non_var = re.sub(r"\$\{[^}]*\}", "", cleaned)
return set(RE_IDENTIFIER_WORDS.findall(non_var))
opts = _extract_literal_kws(opt_str)
ones = _extract_literal_kws(one_str)
multis = _extract_literal_kws(multi_str)
context.list_keywords.update(multis)
fn_key = current_fn_name.lower()
schema = CommandSchema(
options=opts, one_value=ones, multi_value=multis
)
context.dynamic_schemas[fn_key] = schema
# Filesystem markers that indicate a project root boundary. Used by pre_scan_workspace_modules
# to stop walking upward before escaping the repository tree.
_PROJECT_ROOT_SENTINELS = frozenset({".git", ".gitmodules"})
def pre_scan_workspace_modules(
paths: list[str], ctx: FormatterContext | None = None
) -> None:
"""Pre-scans all CMake module files in the repository directory tree to learn dynamic schemas before formatting."""
context = ctx if ctx is not None else WORKSPACE_CONTEXT
module_files = set()
scanned_base_dirs = set()
for p in paths:
path = Path(p)
base_dir = path.resolve() if path.is_dir() else path.resolve().parent
if base_dir in scanned_base_dirs:
continue
scanned_base_dirs.add(base_dir)
# Walk upward to find cmake/modules/, stopping at recognized repository
# root boundaries (.git, .gitmodules) so we don't accidentally scan
# unrelated cmake/modules/ directories in parent directories.
curr = base_dir
while curr and curr != curr.parent:
mod_dir = curr / "cmake" / "modules"
if mod_dir.is_dir():
module_files.update(str(f) for f in mod_dir.glob("*.cmake"))
break
# Stop after checking current dir if we've reached a repository root boundary
if any((curr / sentinel).exists() for sentinel in _PROJECT_ROOT_SENTINELS):
break
curr = curr.parent
for mf in module_files:
try:
with open(mf, "r", encoding="utf-8") as f:
scan_dynamic_schemas(f.read(), ctx=context)
except (OSError, UnicodeDecodeError, LexError) as e:
print(f"Error pre-scanning module {mf}: {e}", file=sys.stderr)
sys.exit(1)
# Regex matching directive comments that should not have a space inserted after '#'
# (e.g., native CMake template directives like #cmakedefine / #cmakedefine01, tool directives
# like # cmake-lint / # cmake-format / #clang-format / #nolint).
#
# NOTE: C preprocessor directives (#include, #define, #pragma, #ifdef, etc.) are included
# here to support embedded C code snippets and C header template code written inside
# CMake files and CMake comments, ensuring that formatting does not corrupt the embedded C.
RE_DIRECTIVE_COMMENT = re.compile(
r"^#(?:[ \t]*cmake-lint|[ \t]*cmake-format|cmakedefine01|cmakedefine|nolint|pragma|include|clang-format|define|undef|ifdef|ifndef|error|warning)\b",
re.IGNORECASE,
)
def _format_line_comment(comment_text: str) -> str:
"""Formats a single line comment, ensuring a space after '#' while preserving special markers (#---, #===, #!).
This function is only called for LINE_COMMENT tokens. Bracket-style comments
(#[=[...]=]) are handled separately by the lexer as BRACKET_COMMENT tokens and
are never passed here.
"""
m_hash = RE_COMMENT_HASH.match(comment_text)
if m_hash:
hash_prefix, rest_comment = m_hash.groups()
if not (
comment_text.startswith("#---")
or comment_text.startswith("#===")
or comment_text.startswith("#!")
or RE_DIRECTIVE_COMMENT.match(comment_text)
):
if rest_comment and not (
rest_comment.startswith(" ") or rest_comment.startswith("\t")
):
return f"{hash_prefix} {rest_comment}"
return comment_text
@dataclass
class CallState:
"""Tracks formatter state across lines during a multi-line command invocation.
Lifecycle:
Created when the opening line of a multi-line command is encountered
(i.e., the line's paren balance is still positive after the command).
Destroyed when the closing ``)``) line is processed or paren balance
drops to zero.
Key state variables:
cmd_name: The command being formatted (lowercase).
base_indent: Indent level of the command itself (used to compute child indents).
paren_balance: Running count of unmatched ``(`` tokens; zero signals end of call.
schema: Resolved CommandSchema providing options/one_value/multi_value sets.
active_kw: The currently open keyword (None if no keyword is active).
active_kw_type: ONE_VALUE, MULTI_VALUE, or None — governs indent of child lines.
active_kw_on_cmd_line: True if active_kw was opened on the command line itself,
which suppresses extra nesting for its child list items.
one_value_count: Number of values consumed under the current ONE_VALUE keyword;
the keyword closes after the first value is seen.
"""
cmd_name: str = ""
base_indent: int = 0
paren_balance: int = 0
schema: CommandSchema | None = None
arg_count: int = 0
first_arg_on_separate_line: bool = False
active_kw: str | None = None
active_kw_type: KeywordType | None = None
active_kw_on_cmd_line: bool = False
one_value_count: int = 0
last_line_was_keyword: bool = False
in_call: bool = True
def __post_init__(self) -> None:
if self.schema is None:
self.schema = CommandSchema()
def reset_kw(self) -> None:
self.active_kw = None
self.active_kw_type = None
self.active_kw_on_cmd_line = False
def is_keyword_token(self, clean_word: str) -> bool:
"""Determines contextually whether an unquoted identifier token is a keyword or a positional argument."""
if not clean_word.isidentifier():
return False
clean_upper = clean_word.upper()
if clean_upper in self.schema.explicit_keywords:
return True
if clean_upper in self.schema.all_keywords:
if self.arg_count > 0 or self.cmd_name in BUILTIN_COMMAND_SCHEMAS:
return True
return False
def process_tokens(self, tokens: list[Token], is_cmd_line: bool = False) -> str:
"""Processes line tokens contextually, updating active keyword state and reconstructing formatted text."""
reconstructed_parts = []
for tok in tokens:
if tok.type == TokenType.WHITESPACE:
reconstructed_parts.append(" ")
elif tok.type in (
TokenType.QUOTED_ARG,
TokenType.BRACKET_ARG,
) or self.cmd_name in {"add_subdirectory", "include"}:
reconstructed_parts.append(tok.value)
if RE_ARG_FORWARDING_VAR.match(tok.value):
self.reset_kw()
elif self.active_kw_type == KeywordType.ONE_VALUE:
self.one_value_count += 1
if self.one_value_count >= 1:
self.reset_kw()
elif tok.type == TokenType.UNQUOTED_ARG:
# Strip trailing syntactic noise (`,`, `;`, `)`) and leading `(` that
# CMake allows to attach to unquoted argument tokens.
clean_word = tok.value.rstrip("),;").lstrip("(")
if RE_ARG_FORWARDING_VAR.match(clean_word):
reconstructed_parts.append(tok.value)
self.reset_kw()
elif self.is_keyword_token(clean_word):
kw = clean_word.upper()
formatted_tok = tok.value.replace(clean_word, kw, 1)
reconstructed_parts.append(formatted_tok)
if kw in self.schema.options:
if self.active_kw_type != KeywordType.MULTI_VALUE:
self.reset_kw()
self.one_value_count = 0
elif kw in self.schema.one_value:
self.active_kw = kw
self.active_kw_type = KeywordType.ONE_VALUE
self.active_kw_on_cmd_line = is_cmd_line
self.one_value_count = 0
elif kw in self.schema.multi_value:
self.active_kw = kw
self.active_kw_type = KeywordType.MULTI_VALUE
self.active_kw_on_cmd_line = is_cmd_line
self.one_value_count = 0
else:
reconstructed_parts.append(tok.value)
if self.active_kw_type == KeywordType.ONE_VALUE:
self.one_value_count += 1
if self.one_value_count >= 1:
self.reset_kw()
else:
reconstructed_parts.append(tok.value)
return "".join(reconstructed_parts)
@dataclass
class _FormatterState:
"""Mutable state carried through a single formatting pass over a CMake file.
Encapsulates all the variables that must be shared across line iterations
(output buffer, pending comments, indent level, active call tracker) so
that the per-line helper functions can read and update them without relying
on Python closure capture.
"""
formatted_lines: list[str] = field(default_factory=list)
pending_comments: list[str] = field(default_factory=list)
block_indent_level: int = 0
current_call_state: CallState | None = None
def flush_comments(self, indent_str: str) -> None:
"""Appends all buffered comments at the given indent level, then clears the buffer."""
for ctext in self.pending_comments:
self.formatted_lines.append(f"{indent_str}{ctext}")
self.pending_comments.clear()
def _trim_line_toks(line_toks: list[Token]) -> list[Token]:
"""Returns a slice of line_toks with leading and trailing WHITESPACE tokens removed."""
start = next(
(i for i, t in enumerate(line_toks) if t.type != TokenType.WHITESPACE),
len(line_toks),
)
end = next(
(
i
for i in range(len(line_toks) - 1, start - 1, -1)
if line_toks[i].type != TokenType.WHITESPACE
),
start - 1,
)
return line_toks[start : end + 1]
def _process_call_continuation_line(
trimmed_line_toks: list[Token],
state: _FormatterState,
) -> None:
"""Handles a continuation line inside an active multi-line command invocation (section 3).
Updates paren_balance on the active CallState. If the line starts with ')' the
call is closed. Otherwise the indent level is computed from the active keyword
context, the line is formatted via CallState.process_tokens(), and the result
is appended to state.formatted_lines.
"""
cs = state.current_call_state
if cs is None:
raise RuntimeError(
"_process_call_continuation_line called with no active CallState"
)
# Safe: the lexer only emits LPAREN/RPAREN for syntactic parens,
# never inside quoted arguments, bracket arguments, or comments.
lparens = sum(1 for tok in trimmed_line_toks if tok.type == TokenType.LPAREN)
rparens = sum(1 for tok in trimmed_line_toks if tok.type == TokenType.RPAREN)
# Check if this line is a standalone closing parenthesis line (e.g. ')' or ') # comment').
non_comment_toks = [
tok
for tok in trimmed_line_toks
if tok.type not in (TokenType.WHITESPACE, TokenType.LINE_COMMENT)
]
is_standalone_close_paren = (
len(non_comment_toks) == 1 and non_comment_toks[0].type == TokenType.RPAREN
)
if is_standalone_close_paren and (cs.paren_balance - 1) <= 0:
cs.in_call = False
comment_indent = INDENT_STR * (
cs.base_indent + LIST_ITEM_INDENT_LEVELS
if cs.last_line_was_keyword
else cs.base_indent + KEYWORD_INDENT_LEVELS
)
line_indent = INDENT_STR * cs.base_indent
cs.reset_kw()
state.flush_comments(comment_indent)
reconstructed_close = "".join(tok.value for tok in trimmed_line_toks).rstrip()
state.formatted_lines.append(f"{line_indent}{reconstructed_close}")
state.current_call_state = None
return
cs.paren_balance += lparens - rparens
# Determine whether the first meaningful token on this line is a keyword or
# an argument-forwarding variable (${ARGN} etc.), as both affect indentation.
first_unquoted: str | None = None
for tok in trimmed_line_toks:
if tok.type in (
TokenType.UNQUOTED_ARG,
TokenType.QUOTED_ARG,
TokenType.BRACKET_ARG,
):
first_unquoted = tok.value
break
line_starts_new_keyword = False
is_arg_var_line = False
if first_unquoted:
clean_first = first_unquoted.rstrip("),;").lstrip("(")
if RE_ARG_FORWARDING_VAR.match(clean_first):
is_arg_var_line = True
elif cs.is_keyword_token(clean_first):
line_starts_new_keyword = True
cs.last_line_was_keyword = line_starts_new_keyword
# Compute indentation level for this continuation line.
if line_starts_new_keyword or is_arg_var_line:
indent_str = INDENT_STR * (cs.base_indent + KEYWORD_INDENT_LEVELS)
elif cs.active_kw is not None:
if cs.active_kw_on_cmd_line:
indent_str = INDENT_STR * (cs.base_indent + KEYWORD_INDENT_LEVELS)
else:
indent_str = INDENT_STR * (cs.base_indent + LIST_ITEM_INDENT_LEVELS)
elif cs.cmd_name == "set" and cs.arg_count >= 1 and cs.first_arg_on_separate_line:
indent_str = INDENT_STR * (cs.base_indent + LIST_ITEM_INDENT_LEVELS)
else:
indent_str = INDENT_STR * (cs.base_indent + KEYWORD_INDENT_LEVELS)
reconstructed_line = cs.process_tokens(trimmed_line_toks, is_cmd_line=False)
cs.arg_count += sum(
1
for tok in trimmed_line_toks
if tok.type
in (TokenType.QUOTED_ARG, TokenType.BRACKET_ARG, TokenType.UNQUOTED_ARG)
)
state.flush_comments(indent_str)
state.formatted_lines.append(f"{indent_str}{reconstructed_line}".rstrip())
if cs.paren_balance <= 0:
cs.in_call = False
state.current_call_state = None
def _process_command_invocation_line(
non_ws_toks: list[Token],
trimmed_line_toks: list[Token],
state: _FormatterState,
file_ctx: FormatterContext,
) -> None:
"""Handles the opening line of a CMake command invocation (section 4).
Applies command casing, strips arguments from empty-closure commands,
computes the opening line's paren balance to decide if a CallState is
needed for continuation lines, and adjusts block_indent_level for
control-flow commands.
"""
cmd_name = non_ws_toks[0].value
lower_cmd = cmd_name.lower()
# Apply canonical casing: module commands keep declared case, built-ins go lowercase.
if lower_cmd in CANONICAL_CMD_CASING:
cmd_name = CANONICAL_CMD_CASING[lower_cmd]
elif (
lower_cmd in BUILTIN_COMMAND_SCHEMAS
or lower_cmd in EMPTY_CLOSE_BLOCKS
or lower_cmd in CONTROL_START_BLOCKS
):
cmd_name = lower_cmd
# Find the opening '(' in the trimmed token list.
lparen_idx = next(
(i for i, tok in enumerate(trimmed_line_toks) if tok.type == TokenType.LPAREN),
-1,
)
rest_tokens = trimmed_line_toks[lparen_idx + 1 :] if lparen_idx != -1 else []
# Empty-closure commands (endif, else, ...) have their arguments stripped while preserving line comments.
if lower_cmd in EMPTY_CLOSE_BLOCKS:
rparen_seen = False
filtered_tokens = []
for rt in rest_tokens:
if rparen_seen:
filtered_tokens.append(rt)
elif rt.type == TokenType.RPAREN:
filtered_tokens.append(rt)
rparen_seen = True
rest_tokens = filtered_tokens
lparens = sum(1 for tok in rest_tokens if tok.type == TokenType.LPAREN)
rparens = sum(1 for tok in rest_tokens if tok.type == TokenType.RPAREN)
line_paren_balance = 1 + lparens - rparens
is_single_line = line_paren_balance <= 0
# Compute indent for this line, adjusting block level for end/middle commands.
if lower_cmd.startswith("end"):
state.block_indent_level = max(0, state.block_indent_level - 1)
indent_str = INDENT_STR * state.block_indent_level
elif lower_cmd in CONTROL_MIDDLE_BLOCKS:
indent_str = INDENT_STR * max(0, state.block_indent_level - 1)
else:
indent_str = INDENT_STR * state.block_indent_level
cmd_schema = get_schema_for_cmd(lower_cmd, ctx=file_ctx)
rest_arg_count = sum(
1
for tok in rest_tokens
if tok.type
in (TokenType.QUOTED_ARG, TokenType.BRACKET_ARG, TokenType.UNQUOTED_ARG)
)
first_arg_on_separate_line = rest_arg_count == 0
temp_state = CallState(cmd_name=lower_cmd, schema=cmd_schema)
rest_formatted = temp_state.process_tokens(rest_tokens, is_cmd_line=True)
reconstructed = f"{indent_str}{cmd_name}({rest_formatted}".rstrip()
state.flush_comments(indent_str)
state.formatted_lines.append(reconstructed)
if not is_single_line:
state.current_call_state = CallState(
cmd_name=lower_cmd,
base_indent=state.block_indent_level,
paren_balance=line_paren_balance,
schema=cmd_schema,
arg_count=rest_arg_count,
first_arg_on_separate_line=first_arg_on_separate_line,
active_kw=temp_state.active_kw,
active_kw_type=temp_state.active_kw_type,
active_kw_on_cmd_line=temp_state.active_kw_on_cmd_line,
one_value_count=temp_state.one_value_count,
)
if lower_cmd in CONTROL_START_BLOCKS:
state.block_indent_level += 1
def format_cmake_content(content: str, ctx: FormatterContext | None = None) -> str:
"""Formats CMake file content using AST token-stream parser.
Applies lowercasing for built-in commands, proper indent nesting for control blocks,
schema-aware keyword upper-casing, multi-line argument indentation (+2 / +4 spaces),
and lookahead comment buffering alignment.
The function dispatches each source line to one of five handlers:
1. Empty line — flush buffered comments and emit a blank line.
2. Comment line — buffer the comment for lookahead alignment.
3. Call continuation line — delegate to _process_call_continuation_line().
4. Command invocation line — delegate to _process_command_invocation_line().
5. Raw fallback — emit the line unchanged (bracket comments, bare tokens).
"""
if not content.strip():
return ""
file_ctx = ctx.clone() if ctx is not None else WORKSPACE_CONTEXT.clone()
tokens = tokenize(content)
scan_dynamic_schemas(content, ctx=file_ctx, tokens=tokens)
# Group tokens into lines (separated by TokenType.NEWLINE).
lines_tokens: list[list[Token]] = []
curr_line: list[Token] = []
for tok in tokens:
if tok.type == TokenType.NEWLINE:
lines_tokens.append(curr_line)
curr_line = []
else:
curr_line.append(tok)
if curr_line or not lines_tokens:
lines_tokens.append(curr_line)
state = _FormatterState()
for line_toks in lines_tokens:
# Strip leading whitespace tokens for line classification.
non_ws_toks = [t for t in line_toks if t.type != TokenType.WHITESPACE]
in_call = (
state.current_call_state is not None and state.current_call_state.in_call
)
# 1. Empty line — flush buffered comments and emit a blank line.
if not non_ws_toks:
if state.pending_comments:
state.flush_comments(
INDENT_STR * (state.block_indent_level + (1 if in_call else 0))
)
state.formatted_lines.append("")
if (
state.current_call_state
and state.current_call_state.in_call
and state.current_call_state.paren_balance <= 0
):
state.current_call_state.in_call = False
state.current_call_state = None
continue
# 2. Comment line — buffer for lookahead alignment.
if non_ws_toks[0].type == TokenType.LINE_COMMENT:
state.pending_comments.append(_format_line_comment(non_ws_toks[0].value))
continue
trimmed_line_toks = _trim_line_toks(line_toks)
# 3. Continuation line inside an active multi-line call.
if state.current_call_state and state.current_call_state.in_call:
_process_call_continuation_line(trimmed_line_toks, state)
continue
# 4. Command invocation line (identifier followed by '(').
if (
non_ws_toks[0].type == TokenType.UNQUOTED_ARG
and len(non_ws_toks) >= 2
and non_ws_toks[1].type == TokenType.LPAREN
):
_process_command_invocation_line(
non_ws_toks, trimmed_line_toks, state, file_ctx
)
continue
# 5. Raw token line fallback (bracket comments, bare unrecognised tokens, etc.).
raw_line_text = "".join(t.value for t in line_toks).rstrip()
state.flush_comments(
INDENT_STR * (state.block_indent_level + (1 if in_call else 0))
)
state.formatted_lines.append(raw_line_text)
if state.pending_comments:
state.flush_comments(INDENT_STR * state.block_indent_level)
return "\n".join(state.formatted_lines).rstrip() + "\n"
def process_file(
filepath: str, inplace: bool = False, dry_run: bool = False, show_diff: bool = False
) -> bool | None:
"""Reads, formats, and optionally writes back a single CMake file.
Returns:
True — file had formatting changes (or would have, in dry-run mode).
False — file is already correctly formatted; no changes needed.
None — a read or write error occurred; the file was not modified.
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
original = f.read()
except Exception as e:
print(f"Error reading file {filepath}: {e}", file=sys.stderr)
return None
try:
formatted = format_cmake_content(original)
except LexError as e:
print(f"Error parsing file {filepath}: {e}", file=sys.stderr)
return None
has_changes = original != formatted
if has_changes:
if show_diff:
diff = difflib.unified_diff(
original.splitlines(keepends=True),
formatted.splitlines(keepends=True),
fromfile=f"a/{filepath}",
tofile=f"b/{filepath}",
)
sys.stdout.writelines(diff)
if inplace and not dry_run:
temp_file = None
try:
target_path = Path(filepath)
temp_file = target_path.with_name(
f".{target_path.name}.tmp_{os.getpid()}"
)
with open(temp_file, "w", encoding="utf-8") as f:
f.write(formatted)
os.replace(temp_file, target_path)
print(f"Formatted {filepath}")
except Exception as e:
if temp_file and os.path.exists(temp_file):
try:
os.remove(temp_file)
except OSError:
pass
print(f"Error writing file {filepath}: {e}", file=sys.stderr)
return None
elif dry_run:
print(f"Formatting needed: {filepath}")
return has_changes
def find_cmake_files(paths: list[str]) -> list[str]:
"""Recursively discovers CMake files (CMakeLists.txt and *.cmake) under the given paths.
Directories starting with '.' and directories starting with 'build' are excluded from traversal.
Plain file paths are included directly without filtering.
Returns files in sorted order for deterministic output across platforms and runs.
"""
cmake_files = []
for p in paths:
path = Path(p)
if path.is_file():
cmake_files.append(str(path))
elif path.is_dir():
for root, dirs, files in path.walk():
dirs[:] = sorted(
d
for d in dirs
if not d.startswith(".") and not d.startswith("build")
)
for f in sorted(files):
if f == "CMakeLists.txt" or f.endswith(".cmake"):
cmake_files.append(str(root / f))
return cmake_files
def main() -> None:
"""Entry point: parses CLI arguments and drives file discovery, pre-scanning, and formatting."""
parser = argparse.ArgumentParser(
description="LLVM and LLVM-libc CMake Formatter Utility",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("paths", nargs="*", help="Files or directories to format.")
parser.add_argument(
"-i", "--inplace", "--fix", action="store_true", help="Format files in-place."
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
help="Check formatting without modifying files (Evaluation Mode).",
)
parser.add_argument(
"--diff", action="store_true", help="Show unified diffs of formatting changes."
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=1,
help="Number of parallel worker processes to use (default: 1).",
)
args = parser.parse_args()
if not args.paths:
if not sys.stdin.isatty():
content = sys.stdin.read()
try:
formatted = format_cmake_content(content)
except LexError as e:
print(f"Error parsing stdin: {e}", file=sys.stderr)
sys.exit(1)
if args.dry_run:
if content != formatted:
if args.diff:
diff = difflib.unified_diff(
content.splitlines(keepends=True),
formatted.splitlines(keepends=True),
fromfile="stdin",
tofile="formatted",
)
sys.stdout.writelines(diff)
sys.exit(1)
else:
sys.exit(0)
else:
sys.stdout.write(formatted)
sys.exit(0)
else:
parser.print_help()
sys.exit(1)
cmake_files = find_cmake_files(args.paths)
if not cmake_files:
print("No CMake files found.", file=sys.stderr)
sys.exit(0)
# Mutates WORKSPACE_CONTEXT in-place so that subsequent format_cmake_content()
# calls (which clone it per-file) inherit the pre-scanned module schemas.
pre_scan_workspace_modules(args.paths)
files_needing_format = 0
if args.jobs > 1 and len(cmake_files) > 1:
from concurrent.futures import ProcessPoolExecutor, as_completed
# Each worker process receives the pre-scanned WORKSPACE_CONTEXT via
# the initializer, which pickles it once per worker. This works
# correctly with any start method ('fork' on Linux, 'spawn' on
# macOS/Windows) — no need to force 'fork'.
with ProcessPoolExecutor(
max_workers=args.jobs,
initializer=_init_worker,
initargs=(WORKSPACE_CONTEXT,),
) as executor:
future_to_file = {
executor.submit(
process_file, fpath, args.inplace, args.dry_run, args.diff
): fpath
for fpath in cmake_files
}
for future in as_completed(future_to_file):
fpath = future_to_file[future]
try:
result = future.result()
if result is True:
files_needing_format += 1
except Exception as e:
print(f"Error processing {fpath}: {e}", file=sys.stderr)
else:
for fpath in cmake_files:
changed = process_file(
fpath, inplace=args.inplace, dry_run=args.dry_run, show_diff=args.diff
)
if changed is True:
files_needing_format += 1
if args.dry_run and files_needing_format > 0:
print(f"\n{files_needing_format} file(s) need formatting.")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()