clang-tools 24.0.0git
check_alphabetical_order.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2#
3# ===-----------------------------------------------------------------------===#
4#
5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6# See https://llvm.org/LICENSE.txt for license information.
7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8#
9# ===-----------------------------------------------------------------------===#
10
11"""
12
13Clang-Tidy Alphabetical Order Checker
14=====================================
15
16Normalize Clang-Tidy documentation with deterministic sorting for linting/tests.
17
18Behavior:
19- Sort entries in docs/clang-tidy/checks/list.md Markdown tables.
20- Sort key sections in docs/ReleaseNotes.md.
21- Detect duplicated entries in 'Changes in existing checks'.
22
23Flags:
24 -o/--output Write normalized content to this path instead of updating docs.
25"""
26
27import argparse
28from collections import defaultdict
29import io
30from operator import itemgetter
31import os
32import re
33import sys
34from typing import (
35 DefaultDict,
36 Final,
37 Iterable,
38 List,
39 NamedTuple,
40 Optional,
41 Sequence,
42 Tuple,
43)
44
45# Matches a {doc}`label <path>` or {doc}`label` reference anywhere in text and
46# captures the label. Used to sort bullet items alphabetically in ReleaseNotes
47# items by their label.
48DOC_LABEL_RN_RE: Final = re.compile(r"\{doc\}`(?P<label>[^`<]+)\s*(?:<[^>]+>)?`")
49
50# Matches a single Markdown table row line in list.md that begins with a
51# {doc} reference, capturing the label. Used to extract the sort key per row.
52DOC_LINE_RE: Final = re.compile(r"^\|\s*\{doc\}`(?P<label>[^`<]+?)\s*<[^>]+>`.*$")
53
54
55EXTRA_DIR: Final = os.path.join(os.path.dirname(__file__), "../..")
56DOCS_DIR: Final = os.path.join(EXTRA_DIR, "docs")
57CLANG_TIDY_DOCS_DIR: Final = os.path.join(DOCS_DIR, "clang-tidy")
58CHECKS_DOCS_DIR: Final = os.path.join(CLANG_TIDY_DOCS_DIR, "checks")
59LIST_DOC: Final = os.path.join(CHECKS_DOCS_DIR, "list.md")
60RELEASE_NOTES_DOC: Final = os.path.join(DOCS_DIR, "ReleaseNotes.md")
61
62
63# Label extracted from :doc:`...`.
64CheckLabel = str
65Lines = List[str]
66BulletBlock = List[str]
67
68# Pair of the extracted label and its block
69BulletItem = Tuple[CheckLabel, BulletBlock]
70
71# Index of the first line of a bullet block within the full lines list.
72BulletStart = int
73
74# All occurrences for a given label.
75DuplicateOccurrences = List[Tuple[BulletStart, BulletBlock]]
76
77
79 """Structured result of parsing a bullet-list section.
80
81 - prefix: lines before the first bullet within the section range.
82 - blocks: list of (label, block-lines) pairs for each bullet block.
83 - suffix: lines after the last bullet within the section range.
84 """
85
86 prefix: Lines
87 blocks: List[BulletItem]
88 suffix: Lines
89
90
92 """Result of scanning bullet blocks within a section range.
93
94 - blocks_with_pos: list of (start_index, block_lines) for each bullet block.
95 - next_index: index where scanning stopped; start of the suffix region.
96 """
97
98 blocks_with_pos: List[Tuple[BulletStart, BulletBlock]]
99 next_index: int
100
101
102def _scan_bullet_blocks(lines: Sequence[str], start: int, end: int) -> ScannedBlocks:
103 """Scan consecutive bullet blocks and return (blocks_with_pos, next_index).
104
105 Each entry in blocks_with_pos is a tuple of (start_index, block_lines).
106 next_index is the index where scanning stopped (start of suffix).
107 """
108 i = start
109 n = end
110 blocks_with_pos: List[Tuple[BulletStart, BulletBlock]] = []
111 while i < n:
112 if not _is_bullet_start(lines[i]):
113 break
114 bstart = i
115 i += 1
116 while i < n and not _is_bullet_start(lines[i]):
117 if lines[i].startswith("#"):
118 break
119 i += 1
120 block: BulletBlock = list(lines[bstart:i])
121 blocks_with_pos.append((bstart, block))
122 return ScannedBlocks(blocks_with_pos, i)
123
124
125def read_text(path: str) -> str:
126 with io.open(path, "r", encoding="utf-8") as f:
127 return f.read()
128
129
130def write_text(path: str, content: str) -> None:
131 with io.open(path, "w", encoding="utf-8", newline="") as f:
132 f.write(content)
133
134
135def _is_markdown_table_header(lines: Sequence[str], index: int) -> bool:
136 return (
137 index + 1 < len(lines)
138 and lines[index].startswith("| Name |")
139 and lines[index + 1].startswith("| ---")
140 )
141
142
143def _normalize_list_md_lines(lines: Sequence[str]) -> List[str]:
144 """Return normalized content of checks list.md as a list of lines."""
145 out: List[str] = []
146 i = 0
147 n = len(lines)
148
149 def check_name(line: str) -> Tuple[int, CheckLabel]:
150 if m := DOC_LINE_RE.match(line):
151 return (0, m.group("label"))
152 return (1, "")
153
154 while i < n:
155 if _is_markdown_table_header(lines, i):
156 out.append(lines[i])
157 out.append(lines[i + 1])
158 i += 2
159
160 entries: List[str] = []
161 while i < n and lines[i].startswith("| "):
162 entries.append(lines[i])
163 i += 1
164
165 entries_sorted = sorted(entries, key=check_name)
166 out.extend(entries_sorted)
167 continue
168
169 out.append(lines[i])
170 i += 1
171
172 return out
173
174
175def normalize_list_md(data: str) -> str:
176 """Normalize list.md content and return a string."""
177 lines = data.splitlines(True)
178 return "".join(_normalize_list_md_lines(lines))
179
180
181def find_heading(lines: Sequence[str], title: str) -> Optional[int]:
182 """Find heading start index for a Markdown #### section heading.
183
184 The function looks for a line equal to `#### {title}`, matching the
185 ReleaseNotes.md style for subsection headings.
186
187 Returns index of the title line, or None if not found.
188 """
189 target = f"#### {title}"
190 for i in range(len(lines)):
191 if lines[i].rstrip("\n") == target:
192 return i
193 return None
194
195
196def extract_label(text: str) -> str:
197 if m := DOC_LABEL_RN_RE.search(text):
198 return m.group("label").strip()
199 return text
200
201
202def _is_bullet_start(line: str) -> bool:
203 return line.startswith("- ")
204
205
206def _parse_bullet_blocks(lines: Sequence[str], start: int, end: int) -> BulletBlocks:
207 i = start
208 n = end
209 first_bullet = i
210 while first_bullet < n and not _is_bullet_start(lines[first_bullet]):
211 first_bullet += 1
212 prefix: Lines = list(lines[i:first_bullet])
213
214 blocks: List[BulletItem] = []
215 res = _scan_bullet_blocks(lines, first_bullet, n)
216 for _, block in res.blocks_with_pos:
217 key: CheckLabel = extract_label("".join(block))
218 blocks.append((key, block))
219
220 suffix: Lines = list(lines[res.next_index : n])
221 return BulletBlocks(prefix, blocks, suffix)
222
223
224def sort_blocks(blocks: Iterable[BulletItem]) -> List[BulletBlock]:
225 """Return blocks sorted deterministically by their extracted label.
226
227 Duplicates are preserved; merging is left to authors to handle manually.
228 """
229 return list(map(itemgetter(1), sorted(blocks, key=itemgetter(0))))
230
231
233 lines: Sequence[str], title: str
234) -> List[Tuple[CheckLabel, DuplicateOccurrences]]:
235 """Return detailed duplicate info as (key, [(start_idx, block_lines), ...]).
236
237 start_idx is the 0-based index of the first line of the bullet block in
238 the original lines list. Only keys with more than one occurrence are
239 returned, and occurrences are listed in the order they appear.
240 """
241 bounds = _find_section_bounds(lines, title, None)
242 if bounds is None:
243 return []
244 _, sec_start, sec_end = bounds
245
246 i = sec_start
247 n = sec_end
248
249 while i < n and not _is_bullet_start(lines[i]):
250 i += 1
251
252 blocks_with_pos: List[Tuple[CheckLabel, BulletStart, BulletBlock]] = []
253 res = _scan_bullet_blocks(lines, i, n)
254 for bstart, block in res.blocks_with_pos:
255 key = extract_label(block[0])
256 blocks_with_pos.append((key, bstart, block))
257
258 grouped: DefaultDict[CheckLabel, DuplicateOccurrences] = defaultdict(list)
259 for key, start, block in blocks_with_pos:
260 grouped[key].append((start, block))
261
262 result: List[Tuple[CheckLabel, DuplicateOccurrences]] = []
263 for key, occs in grouped.items():
264 if len(occs) > 1:
265 result.append((key, occs))
266
267 result.sort(key=itemgetter(0))
268 return result
269
270
272 lines: Sequence[str], title: str, next_title: Optional[str]
273) -> Optional[Tuple[int, int, int]]:
274 """Return (h_start, sec_start, sec_end) for section `title`.
275
276 - h_start: index of the section title line
277 - sec_start: index of the first content line after the heading
278 - sec_end: index of the first line of the next section title (or end)
279 """
280 if (h_start := find_heading(lines, title)) is None:
281 return None
282
283 sec_start = h_start + 1
284
285 # Determine end of section either from next_title or by scanning.
286 if next_title is not None:
287 if (h_end := find_heading(lines, next_title)) is None:
288 # Scan forward to the next Markdown heading of any level.
289 h_end = sec_start
290 while h_end < len(lines):
291 if lines[h_end].startswith("#"):
292 break
293 h_end += 1
294 sec_end = h_end
295 else:
296 # Scan to end or until a Markdown heading is found.
297 h_end = sec_start
298 while h_end < len(lines):
299 if lines[h_end].startswith("#"):
300 break
301 h_end += 1
302 sec_end = h_end
303
304 return h_start, sec_start, sec_end
305
306
308 lines: Sequence[str], title: str, next_title: Optional[str]
309) -> List[str]:
310 """Normalize a single release-notes section and return updated lines."""
311 if (bounds := _find_section_bounds(lines, title, next_title)) is None:
312 return list(lines)
313 _, sec_start, sec_end = bounds
314
315 prefix, blocks, suffix = _parse_bullet_blocks(lines, sec_start, sec_end)
316 sorted_blocks = sort_blocks(blocks)
317
318 new_section: List[str] = []
319 new_section.extend(prefix)
320 for i_b, b in enumerate(sorted_blocks):
321 if i_b > 0 and (
322 not new_section or (new_section and new_section[-1].strip() != "")
323 ):
324 new_section.append("\n")
325 new_section.extend(b)
326 new_section.extend(suffix)
327
328 return list(lines[:sec_start]) + new_section + list(lines[sec_end:])
329
330
331def normalize_release_notes(lines: Sequence[str]) -> str:
332 sections = ["New checks", "New check aliases", "Changes in existing checks"]
333
334 out = list(lines)
335
336 for idx in range(len(sections) - 1, -1, -1):
337 title = sections[idx]
338 next_title = sections[idx + 1] if idx + 1 < len(sections) else None
339 out = _normalize_release_notes_section(out, title, next_title)
340
341 return "".join(out)
342
343
344def _emit_duplicate_report(lines: Sequence[str], title: str) -> Optional[str]:
345 if not (dups_detail := find_duplicate_entries(lines, title)):
346 return None
347 out: List[str] = []
348 out.append(f"Error: Duplicate entries in '{title}'.\n")
349 out.append("\nPlease merge these entries into a single bullet point.\n")
350 for key, occs in dups_detail:
351 out.append(f"\n-- Duplicate: {key}\n")
352 for start_idx, block in occs:
353 out.append(f"- At line {start_idx + 1}:\n")
354 out.append("".join(block))
355 if not (block and block[-1].endswith("\n")):
356 out.append("\n")
357 return "".join(out)
358
359
360def process_release_notes(out_path: str, rn_doc: str) -> int:
361 text = read_text(rn_doc)
362 lines = text.splitlines(True)
363 normalized = normalize_release_notes(lines)
364 write_text(out_path, normalized)
365
366 # Prefer reporting ordering issues first; let diff fail the test.
367 if text != normalized:
368 sys.stderr.write(
369 "\nEntries in 'clang-tools-extra/docs/ReleaseNotes.md' are not alphabetically sorted.\n"
370 "Fix the ordering by applying diff printed below.\n\n"
371 )
372 return 0
373
374 # Ordering is clean then enforce duplicates.
375 if report := _emit_duplicate_report(lines, "Changes in existing checks"):
376 sys.stderr.write(report)
377 return 3
378 return 0
379
380
381def process_checks_list(out_path: str, list_doc: str) -> int:
382 text = read_text(list_doc)
383 normalized = normalize_list_md(text)
384
385 if text != normalized:
386 sys.stderr.write(
387 "\nChecks in 'clang-tools-extra/docs/clang-tidy/checks/list.md' tables are not alphabetically sorted.\n"
388 "Fix the ordering by applying diff printed below.\n\n"
389 )
390
391 write_text(out_path, normalized)
392 return 0
393
394
395def main(argv: Sequence[str]) -> int:
396 ap = argparse.ArgumentParser()
397 ap.add_argument("-o", "--output", dest="out", default=None)
398 args = ap.parse_args(argv)
399
400 list_doc, rn_doc = (os.path.normpath(LIST_DOC), os.path.normpath(RELEASE_NOTES_DOC))
401
402 if args.out:
403 out_path = args.out
404 out_lower = os.path.basename(out_path).lower()
405 if "release" in out_lower:
406 return process_release_notes(out_path, rn_doc)
407 else:
408 return process_checks_list(out_path, list_doc)
409
410 process_checks_list(list_doc, list_doc)
411 return process_release_notes(rn_doc, rn_doc)
412
413
414if __name__ == "__main__":
415 sys.exit(main(sys.argv[1:]))
List[Tuple[CheckLabel, DuplicateOccurrences]] find_duplicate_entries(Sequence[str] lines, str title)
List[str] _normalize_list_md_lines(Sequence[str] lines)
List[BulletBlock] sort_blocks(Iterable[BulletItem] blocks)
Optional[int] find_heading(Sequence[str] lines, str title)
None write_text(str path, str content)
Optional[Tuple[int, int, int]] _find_section_bounds(Sequence[str] lines, str title, Optional[str] next_title)
int process_checks_list(str out_path, str list_doc)
bool _is_markdown_table_header(Sequence[str] lines, int index)
List[str] _normalize_release_notes_section(Sequence[str] lines, str title, Optional[str] next_title)
Optional[str] _emit_duplicate_report(Sequence[str] lines, str title)
str normalize_release_notes(Sequence[str] lines)
ScannedBlocks _scan_bullet_blocks(Sequence[str] lines, int start, int end)
int process_release_notes(str out_path, str rn_doc)
BulletBlocks _parse_bullet_blocks(Sequence[str] lines, int start, int end)