Skip to content

Commit 012adf0

Browse files
committed
Add clang-tidy GH action
1 parent 8aa7f97 commit 012adf0

File tree

2 files changed

+406
-0
lines changed

2 files changed

+406
-0
lines changed
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env python3
2+
# clang_tidy_html_report.py
3+
# Parse clang-tidy stdout logs -> rich HTML (filterable)
4+
# - Filters out "note" severity entirely
5+
# - Suppresses checks/messages via hard-coded sets and env overrides
6+
# - Emits post-filter counts (--counts) and can fail the process (--fail-on)
7+
8+
import re, sys, html, argparse, pathlib, os, json
9+
from string import Template
10+
from collections import defaultdict, Counter
11+
12+
# ---------- configurable suppression ----------
13+
# Hide diagnostics whose check name exactly matches any of these:
14+
SUPPRESS_CHECKS = {
15+
"IgnoreClassesWithAllMemberVariablesBeingPublic",
16+
"clang-diagnostic-error",
17+
"clang-analyzer-optin.cplusplus.VirtualCall",
18+
}
19+
# Hide diagnostics whose *message* contains any of these substrings:
20+
SUPPRESS_MSG_SUBSTR = {
21+
"IgnoreClassesWithAllMemberVariablesBeingPublic",
22+
"use of undeclared identifier",
23+
"file not found",
24+
"unknown type name",
25+
"no matching function for call to",
26+
}
27+
# Optional env overrides (comma-separated lists)
28+
SUPPRESS_CHECKS |= {s.strip() for s in os.getenv("CT_SUPPRESS_CHECKS", "").split(",") if s.strip()}
29+
SUPPRESS_MSG_SUBSTR |= {s.strip() for s in os.getenv("CT_SUPPRESS_MSG_SUBSTR", "").split(",") if s.strip()}
30+
# ------------------------------------------------
31+
32+
ROW_RE = re.compile(
33+
r'^(?P<file>[^:\n]+):(?P<line>\d+):(?P<col>\d+):\s+'
34+
r'(?P<sev>warning|error|note):\s+'
35+
r'(?P<msg>.*?)(?:\s\[(?P<check>[^\]]+)\])?\s*$'
36+
)
37+
38+
HTML = Template("""<!doctype html>
39+
<html lang="en"><head>
40+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
41+
<title>Clang-Tidy Report</title>
42+
<style>
43+
:root{--bg:#0b0d10;--fg:#e6edf3;--muted:#9aa7b1;--row:#11151a;--accent:#2f81f7;--warn:#f5a623;--err:#ff4d4f}
44+
body{background:var(--bg);color:var(--fg);font:14px/1.45 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:24px}
45+
h1{margin:0 0 16px 0;font-size:22px}
46+
.muted{color:var(--muted)}
47+
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 16px 0}
48+
.chip{background:#1a2129;border:1px solid #223041;padding:4px 10px;border-radius:999px;font-size:12px}
49+
.chip strong{color:var(--fg)}
50+
.filters{display:flex;gap:12px;margin:14px 0 18px 0;align-items:center}
51+
select,input[type="search"]{background:#0f141a;color:var(--fg);border:1px solid #223041;padding:6px 10px;border-radius:8px}
52+
table{width:100%;border-collapse:collapse}
53+
th,td{text-align:left;padding:10px 8px;border-bottom:1px solid #1f2630}
54+
tr{background:var(--row)} tr:hover{background:#141a22}
55+
th{position:sticky;top:0;background:#0e141b;z-index:2}
56+
code{background:#0f141a;padding:2px 6px;border-radius:6px}
57+
.sev-warning{color:var(--warn)} .sev-error{color:var(--err)}
58+
.footer{margin-top:18px;font-size:12px;color:var(--muted)} .nowrap{white-space:nowrap}
59+
</style>
60+
</head><body>
61+
<h1>Clang-Tidy Report</h1>
62+
<div class="chips">
63+
<div class="chip"><strong>Total</strong> $total</div>
64+
<div class="chip"><span class="sev-error"><strong>Errors</strong></span> $n_err</div>
65+
<div class="chip"><span class="sev-warning"><strong>Warnings</strong></span> $n_warn</div>
66+
<div class="chip"><strong>Files</strong> $files_count</div>
67+
<div class="chip"><strong>Checks</strong> $checks_count</div>
68+
</div>
69+
70+
<div class="filters">
71+
<label>Severity:
72+
<select id="sev">
73+
<option value="">All</option>
74+
<option value="error">Error</option>
75+
<option value="warning">Warning</option>
76+
</select>
77+
</label>
78+
<label>Check:
79+
<select id="check">
80+
<option value="">All</option>
81+
$check_opts
82+
</select>
83+
</label>
84+
<label class="nowrap">Search:
85+
<input id="q" type="search" placeholder="file / message contains…">
86+
</label>
87+
</div>
88+
89+
<table id="tbl">
90+
<thead><tr>
91+
<th class="nowrap">Severity</th>
92+
<th>Check</th>
93+
<th>Message</th>
94+
<th>File</th>
95+
<th class="nowrap">Line:Col</th>
96+
</tr></thead>
97+
<tbody>
98+
$rows
99+
$empty_row
100+
</tbody>
101+
</table>
102+
103+
<div class="section">
104+
<h2>By Check</h2>
105+
<table>
106+
<thead><tr><th>Check</th><th>Count</th><th>Examples</th></tr></thead>
107+
<tbody>$by_check_rows</tbody>
108+
</table>
109+
</div>
110+
111+
<div class="footer muted">Config: .clang-tidy • Generated from clang-tidy logs</div>
112+
113+
<script>
114+
(() => {
115+
const sevSel=document.getElementById('sev');
116+
const chkSel=document.getElementById('check');
117+
const q=document.getElementById('q');
118+
const rows=Array.from(document.querySelectorAll('#tbl tbody tr[data-sev]'));
119+
function matches(r){
120+
const s=sevSel.value,c=chkSel.value,t=q.value.toLowerCase().trim();
121+
if(s && r.dataset.sev!==s) return false;
122+
if(c && r.dataset.check!==c) return false;
123+
if(t){const hay=(r.dataset.file+' '+r.dataset.msg).toLowerCase(); if(!hay.includes(t)) return false;}
124+
return true;
125+
}
126+
function apply(){
127+
let any=false;
128+
rows.forEach(r=>{const ok=matches(r); r.style.display=ok?'':'none'; if(ok) any=true;});
129+
document.getElementById('empty')?.remove();
130+
if(!any){
131+
const tr=document.createElement('tr'); tr.id='empty';
132+
tr.innerHTML=`<td colspan="5" class="muted">No rows match.</td>`;
133+
document.querySelector('#tbl tbody').appendChild(tr);
134+
}
135+
}
136+
[sevSel,chkSel].forEach(e=>e.addEventListener('change',apply));
137+
q.addEventListener('input',apply);
138+
})();
139+
</script>
140+
</body></html>
141+
""")
142+
143+
def parse_logs(paths):
144+
entries=[]
145+
for p in paths:
146+
try:
147+
text=pathlib.Path(p).read_text(encoding='utf-8', errors='replace').splitlines()
148+
except Exception:
149+
continue
150+
for line in text:
151+
m=ROW_RE.match(line.strip())
152+
if not m:
153+
continue
154+
d=m.groupdict()
155+
entries.append({
156+
'file': d['file'],
157+
'line': int(d['line']),
158+
'col': int(d['col']),
159+
'sev': d['sev'],
160+
'msg': d['msg'],
161+
'check': d.get('check') or '',
162+
})
163+
return entries
164+
165+
def main():
166+
ap=argparse.ArgumentParser()
167+
ap.add_argument("--logs", nargs='+', required=True, help="One or more clang-tidy *.txt logs")
168+
ap.add_argument("--out", required=True, help="Output HTML path")
169+
# counts + failure control
170+
ap.add_argument("--counts", help="Write post-filter counts to this JSON file")
171+
ap.add_argument("--fail-on", choices=["none", "errors", "warnings"], default="none",
172+
help="Exit non-zero if errors/warnings remain after filters")
173+
174+
a=ap.parse_args()
175+
176+
items=parse_logs(a.logs)
177+
178+
# 1) drop notes entirely
179+
items=[it for it in items if it['sev'] != 'note']
180+
181+
# 2) apply check/message suppressions
182+
def _suppressed(it):
183+
if it['check'] in SUPPRESS_CHECKS:
184+
return True
185+
msg = it['msg']
186+
for sub in SUPPRESS_MSG_SUBSTR:
187+
if sub and sub in msg:
188+
return True
189+
return False
190+
items=[it for it in items if not _suppressed(it)]
191+
192+
# sort with errors first, then warnings, then by file/loc/check
193+
items.sort(key=lambda x: (x['sev']!='error', x['sev']!='warning', x['file'], x['line'], x['col'], x['check']))
194+
195+
by_check=defaultdict(list)
196+
files=set()
197+
sev_ct=Counter()
198+
for it in items:
199+
by_check[it['check']].append(it)
200+
files.add(it['file'])
201+
sev_ct[it['sev']]+=1
202+
203+
esc=lambda s: html.escape(str(s or ""))
204+
205+
row_html=[]
206+
for it in items:
207+
row_html.append(
208+
f"<tr data-sev='{esc(it['sev'])}' data-check='{esc(it['check'])}' "
209+
f"data-file='{esc(it['file'])}' data-msg='{esc(it['msg'])}'>"
210+
f"<td class='sev-{esc(it['sev'])}'>{esc(it['sev'])}</td>"
211+
f"<td><code>{esc(it['check']) or '—'}</code></td>"
212+
f"<td>{esc(it['msg'])}</td>"
213+
f"<td class='nowrap'>{esc(it['file'])}</td>"
214+
f"<td class='nowrap'>{it['line']}:{it['col']}</td>"
215+
f"</tr>"
216+
)
217+
empty_row = "" if row_html else "<tr id='empty'><td colspan='5' class='muted'>No diagnostics.</td></tr>"
218+
219+
check_opts="\n".join(
220+
f"<option value='{esc(k)}'>{esc(k)} ({len(v)})</option>"
221+
for k,v in sorted(by_check.items(), key=lambda kv: (-len(kv[1]), kv[0])) if k
222+
)
223+
224+
by_check_rows=[]
225+
for chk,lst in sorted(by_check.items(), key=lambda kv: (-len(kv[1]), kv[0])):
226+
examples="".join(
227+
f"<div><span class='nowrap'>{esc(it['file'])}:{it['line']}</span> — {esc(it['msg'])}</div>"
228+
for it in lst[:3]
229+
)
230+
# NOTE: keep by_check_rows even if empty
231+
by_check_rows.append(f"<tr><td><code>{esc(chk) or '—'}</code></td><td>{len(lst)}</td><td>{examples}</td></tr>")
232+
by_check_html = "\n".join(by_check_rows) if by_check_rows else "<tr><td colspan='3' class='muted'>No diagnostics.</td></tr>"
233+
234+
html_out = HTML.substitute(
235+
total=len(items),
236+
n_err=sev_ct['error'],
237+
n_warn=sev_ct['warning'],
238+
files_count=len(files),
239+
checks_count=sum(1 for k in by_check if k),
240+
check_opts=check_opts,
241+
rows="\n".join(row_html),
242+
empty_row=empty_row,
243+
by_check_rows=by_check_html
244+
)
245+
246+
outp=pathlib.Path(a.out)
247+
outp.parent.mkdir(parents=True, exist_ok=True)
248+
outp.write_text(html_out, encoding='utf-8')
249+
250+
# write counts + optional failure
251+
if a.counts:
252+
with open(a.counts, "w", encoding="utf-8") as f:
253+
json.dump({
254+
"errors": int(sev_ct["error"]),
255+
"warnings": int(sev_ct["warning"]),
256+
"total": int(len(items)),
257+
}, f)
258+
259+
if a.fail_on == "errors" and sev_ct["error"] > 0:
260+
sys.exit(1)
261+
if a.fail_on == "warnings" and (sev_ct["error"] > 0 or sev_ct["warning"] > 0):
262+
sys.exit(1)
263+
264+
if __name__=="__main__":
265+
main()

0 commit comments

Comments
 (0)