codeclone 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeclone/__init__.py +16 -0
- codeclone/baseline.py +21 -9
- codeclone/blockhash.py +10 -1
- codeclone/blocks.py +26 -16
- codeclone/cache.py +20 -6
- codeclone/cfg.py +338 -0
- codeclone/cli.py +357 -93
- codeclone/extractor.py +92 -32
- codeclone/fingerprint.py +11 -1
- codeclone/html_report.py +936 -0
- codeclone/normalize.py +73 -26
- codeclone/report.py +29 -13
- codeclone/scanner.py +24 -4
- codeclone-1.2.0.dist-info/METADATA +264 -0
- codeclone-1.2.0.dist-info/RECORD +19 -0
- {codeclone-1.0.0.dist-info → codeclone-1.2.0.dist-info}/WHEEL +1 -1
- codeclone-1.0.0.dist-info/METADATA +0 -211
- codeclone-1.0.0.dist-info/RECORD +0 -17
- {codeclone-1.0.0.dist-info → codeclone-1.2.0.dist-info}/entry_points.txt +0 -0
- {codeclone-1.0.0.dist-info → codeclone-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codeclone-1.0.0.dist-info → codeclone-1.2.0.dist-info}/top_level.txt +0 -0
codeclone/html_report.py
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeClone — AST and CFG-based code clone detector for Python
|
|
3
|
+
focused on architectural duplication.
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2026 Den Rozhnovskiy
|
|
6
|
+
Licensed under the MIT License.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import itertools
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from string import Template
|
|
16
|
+
from typing import Any, Optional, Iterable
|
|
17
|
+
|
|
18
|
+
from codeclone import __version__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ============================
|
|
22
|
+
# Pairwise
|
|
23
|
+
# ============================
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
|
27
|
+
a, b = itertools.tee(iterable)
|
|
28
|
+
next(b, None)
|
|
29
|
+
return zip(a, b)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ============================
|
|
33
|
+
# Code snippet infrastructure
|
|
34
|
+
# ============================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _Snippet:
|
|
39
|
+
filepath: str
|
|
40
|
+
start_line: int
|
|
41
|
+
end_line: int
|
|
42
|
+
code_html: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _FileCache:
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._lines: dict[str, list[str]] = {}
|
|
48
|
+
|
|
49
|
+
def get_lines(self, filepath: str) -> list[str]:
|
|
50
|
+
if filepath not in self._lines:
|
|
51
|
+
try:
|
|
52
|
+
text = Path(filepath).read_text("utf-8")
|
|
53
|
+
except UnicodeDecodeError:
|
|
54
|
+
text = Path(filepath).read_text("utf-8", errors="replace")
|
|
55
|
+
self._lines[filepath] = text.splitlines()
|
|
56
|
+
return self._lines[filepath]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _try_pygments(code: str) -> Optional[str]:
|
|
60
|
+
try:
|
|
61
|
+
from pygments import highlight
|
|
62
|
+
from pygments.formatters import HtmlFormatter
|
|
63
|
+
from pygments.lexers import PythonLexer
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
result = highlight(code, PythonLexer(), HtmlFormatter(nowrap=True))
|
|
68
|
+
return result if isinstance(result, str) else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pygments_css(style_name: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Returns CSS for pygments tokens. Scoped to `.codebox` to avoid leaking styles.
|
|
74
|
+
If Pygments is not available or style missing, returns "".
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
from pygments.formatters import HtmlFormatter
|
|
78
|
+
except Exception:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
fmt = HtmlFormatter(style=style_name)
|
|
83
|
+
except Exception:
|
|
84
|
+
try:
|
|
85
|
+
fmt = HtmlFormatter()
|
|
86
|
+
except Exception:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
|
|
91
|
+
return fmt.get_style_defs(".codebox")
|
|
92
|
+
except Exception:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _prefix_css(css: str, prefix: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Prefix every selector block with `prefix `.
|
|
99
|
+
Safe enough for pygments CSS which is mostly selector blocks and comments.
|
|
100
|
+
"""
|
|
101
|
+
out_lines: list[str] = []
|
|
102
|
+
for line in css.splitlines():
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
if not stripped:
|
|
105
|
+
out_lines.append(line)
|
|
106
|
+
continue
|
|
107
|
+
if (
|
|
108
|
+
stripped.startswith("/*")
|
|
109
|
+
or stripped.startswith("*")
|
|
110
|
+
or stripped.startswith("*/")
|
|
111
|
+
):
|
|
112
|
+
out_lines.append(line)
|
|
113
|
+
continue
|
|
114
|
+
# Selector lines usually end with `{
|
|
115
|
+
if "{" in line:
|
|
116
|
+
# naive prefix: split at "{", prefix selector part
|
|
117
|
+
before, after = line.split("{", 1)
|
|
118
|
+
sel = before.strip()
|
|
119
|
+
if sel:
|
|
120
|
+
out_lines.append(f"{prefix} {sel} {{ {after}".rstrip())
|
|
121
|
+
else:
|
|
122
|
+
out_lines.append(line)
|
|
123
|
+
else:
|
|
124
|
+
out_lines.append(line)
|
|
125
|
+
return "\n".join(out_lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_code_block(
|
|
129
|
+
*,
|
|
130
|
+
filepath: str,
|
|
131
|
+
start_line: int,
|
|
132
|
+
end_line: int,
|
|
133
|
+
file_cache: _FileCache,
|
|
134
|
+
context: int,
|
|
135
|
+
max_lines: int,
|
|
136
|
+
) -> _Snippet:
|
|
137
|
+
lines = file_cache.get_lines(filepath)
|
|
138
|
+
|
|
139
|
+
s = max(1, start_line - context)
|
|
140
|
+
e = min(len(lines), end_line + context)
|
|
141
|
+
|
|
142
|
+
if e - s + 1 > max_lines:
|
|
143
|
+
e = s + max_lines - 1
|
|
144
|
+
|
|
145
|
+
numbered: list[tuple[bool, str]] = []
|
|
146
|
+
for lineno in range(s, e + 1):
|
|
147
|
+
line = lines[lineno - 1]
|
|
148
|
+
hit = start_line <= lineno <= end_line
|
|
149
|
+
numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
|
|
150
|
+
|
|
151
|
+
raw = "\n".join(text for _, text in numbered)
|
|
152
|
+
highlighted = _try_pygments(raw)
|
|
153
|
+
|
|
154
|
+
if highlighted is None:
|
|
155
|
+
rendered: list[str] = []
|
|
156
|
+
for hit, text in numbered:
|
|
157
|
+
cls = "hitline" if hit else "line"
|
|
158
|
+
rendered.append(f'<div class="{cls}">{html.escape(text)}</div>')
|
|
159
|
+
body = "\n".join(rendered)
|
|
160
|
+
else:
|
|
161
|
+
body = highlighted
|
|
162
|
+
|
|
163
|
+
return _Snippet(
|
|
164
|
+
filepath=filepath,
|
|
165
|
+
start_line=start_line,
|
|
166
|
+
end_line=end_line,
|
|
167
|
+
code_html=f'<pre class="codebox"><code>{body}</code></pre>',
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ============================
|
|
172
|
+
# HTML report builder
|
|
173
|
+
# ============================
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _escape(v: Any) -> str:
|
|
177
|
+
return html.escape("" if v is None else str(v))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
|
|
181
|
+
return (
|
|
182
|
+
-len(items),
|
|
183
|
+
-max(int(i.get("loc") or i.get("size") or 0) for i in items),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
REPORT_TEMPLATE = Template(r"""
|
|
188
|
+
<!doctype html>
|
|
189
|
+
<html lang="en" data-theme="dark">
|
|
190
|
+
<head>
|
|
191
|
+
<meta charset="utf-8">
|
|
192
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
193
|
+
<title>${title}</title>
|
|
194
|
+
|
|
195
|
+
<style>
|
|
196
|
+
/* ============================
|
|
197
|
+
CodeClone UI/UX
|
|
198
|
+
============================ */
|
|
199
|
+
|
|
200
|
+
:root {
|
|
201
|
+
--bg: #0d1117;
|
|
202
|
+
--panel: #161b22;
|
|
203
|
+
--panel2: #21262d;
|
|
204
|
+
--text: #c9d1d9;
|
|
205
|
+
--muted: #8b949e;
|
|
206
|
+
--border: #30363d;
|
|
207
|
+
--border2: #6e7681;
|
|
208
|
+
--accent: #58a6ff;
|
|
209
|
+
--accent2: rgba(56, 139, 253, 0.15);
|
|
210
|
+
--good: #3fb950;
|
|
211
|
+
--shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
212
|
+
--shadow2: 0 4px 12px rgba(0,0,0,0.2);
|
|
213
|
+
--radius: 6px;
|
|
214
|
+
--radius2: 8px;
|
|
215
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
216
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
html[data-theme="light"] {
|
|
220
|
+
--bg: #ffffff;
|
|
221
|
+
--panel: #f6f8fa;
|
|
222
|
+
--panel2: #eaeef2;
|
|
223
|
+
--text: #24292f;
|
|
224
|
+
--muted: #57606a;
|
|
225
|
+
--border: #d0d7de;
|
|
226
|
+
--border2: #afb8c1;
|
|
227
|
+
--accent: #0969da;
|
|
228
|
+
--accent2: rgba(84, 174, 255, 0.2);
|
|
229
|
+
--good: #1a7f37;
|
|
230
|
+
--shadow: 0 8px 24px rgba(140,149,159,0.2);
|
|
231
|
+
--shadow2: 0 4px 12px rgba(140,149,159,0.1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
* { box-sizing: border-box; }
|
|
235
|
+
|
|
236
|
+
body {
|
|
237
|
+
margin: 0;
|
|
238
|
+
background: var(--bg);
|
|
239
|
+
color: var(--text);
|
|
240
|
+
font-family: var(--font);
|
|
241
|
+
line-height: 1.5;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.container {
|
|
245
|
+
max-width: 1400px;
|
|
246
|
+
margin: 0 auto;
|
|
247
|
+
padding: 20px 20px 80px;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.topbar {
|
|
251
|
+
position: sticky;
|
|
252
|
+
top: 0;
|
|
253
|
+
z-index: 100;
|
|
254
|
+
backdrop-filter: blur(8px);
|
|
255
|
+
-webkit-backdrop-filter: blur(8px);
|
|
256
|
+
background: var(--bg);
|
|
257
|
+
border-bottom: 1px solid var(--border);
|
|
258
|
+
opacity: 0.98;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.topbar-inner {
|
|
262
|
+
display: flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
justify-content: space-between;
|
|
265
|
+
height: 60px;
|
|
266
|
+
padding: 0 20px;
|
|
267
|
+
max-width: 1400px;
|
|
268
|
+
margin: 0 auto;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.brand {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 12px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.brand h1 {
|
|
278
|
+
margin: 0;
|
|
279
|
+
font-size: 18px;
|
|
280
|
+
font-weight: 600;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.brand .sub {
|
|
284
|
+
color: var(--muted);
|
|
285
|
+
font-size: 13px;
|
|
286
|
+
background: var(--panel2);
|
|
287
|
+
padding: 2px 8px;
|
|
288
|
+
border-radius: 99px;
|
|
289
|
+
font-weight: 500;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.btn {
|
|
293
|
+
display: inline-flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
justify-content: center;
|
|
296
|
+
gap: 6px;
|
|
297
|
+
padding: 6px 12px;
|
|
298
|
+
border-radius: 6px;
|
|
299
|
+
border: 1px solid var(--border);
|
|
300
|
+
background: var(--panel);
|
|
301
|
+
color: var(--text);
|
|
302
|
+
cursor: pointer;
|
|
303
|
+
font-size: 13px;
|
|
304
|
+
font-weight: 500;
|
|
305
|
+
transition: 0.2s;
|
|
306
|
+
height: 32px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.btn:hover {
|
|
310
|
+
border-color: var(--border2);
|
|
311
|
+
background: var(--panel2);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.btn.ghost {
|
|
315
|
+
background: transparent;
|
|
316
|
+
border-color: transparent;
|
|
317
|
+
padding: 4px;
|
|
318
|
+
width: 28px;
|
|
319
|
+
height: 28px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.select {
|
|
323
|
+
padding: 0 24px 0 8px;
|
|
324
|
+
height: 32px;
|
|
325
|
+
border-radius: 6px;
|
|
326
|
+
border: 1px solid var(--border);
|
|
327
|
+
background: var(--panel);
|
|
328
|
+
color: var(--text);
|
|
329
|
+
font-size: 13px;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.section {
|
|
333
|
+
margin-top: 32px;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.section-head {
|
|
337
|
+
display: flex;
|
|
338
|
+
flex-direction: column;
|
|
339
|
+
gap: 16px;
|
|
340
|
+
margin-bottom: 16px;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.section-head h2 {
|
|
344
|
+
margin: 0;
|
|
345
|
+
font-size: 20px;
|
|
346
|
+
font-weight: 600;
|
|
347
|
+
display: flex;
|
|
348
|
+
align-items: center;
|
|
349
|
+
gap: 12px;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.section-toolbar {
|
|
353
|
+
display: flex;
|
|
354
|
+
justify-content: space-between;
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 16px;
|
|
357
|
+
flex-wrap: wrap;
|
|
358
|
+
padding: 12px;
|
|
359
|
+
background: var(--panel);
|
|
360
|
+
border: 1px solid var(--border);
|
|
361
|
+
border-radius: 6px;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.search-wrap {
|
|
365
|
+
display: flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 8px;
|
|
368
|
+
padding: 4px 8px;
|
|
369
|
+
border-radius: 6px;
|
|
370
|
+
border: 1px solid var(--border);
|
|
371
|
+
background: var(--bg);
|
|
372
|
+
min-width: 300px;
|
|
373
|
+
height: 32px;
|
|
374
|
+
}
|
|
375
|
+
.search-wrap:focus-within {
|
|
376
|
+
border-color: var(--accent);
|
|
377
|
+
box-shadow: 0 0 0 2px var(--accent2);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.search-ico {
|
|
381
|
+
color: var(--muted);
|
|
382
|
+
display: flex;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.search {
|
|
386
|
+
width: 100%;
|
|
387
|
+
border: none;
|
|
388
|
+
outline: none;
|
|
389
|
+
background: transparent;
|
|
390
|
+
color: var(--text);
|
|
391
|
+
font-size: 13px;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.segmented {
|
|
395
|
+
display: inline-flex;
|
|
396
|
+
background: var(--panel2);
|
|
397
|
+
padding: 2px;
|
|
398
|
+
border-radius: 6px;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.btn.seg {
|
|
402
|
+
border: none;
|
|
403
|
+
background: transparent;
|
|
404
|
+
height: 28px;
|
|
405
|
+
font-size: 12px;
|
|
406
|
+
}
|
|
407
|
+
.btn.seg:hover {
|
|
408
|
+
background: var(--bg);
|
|
409
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.pager {
|
|
413
|
+
display: inline-flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
gap: 8px;
|
|
416
|
+
font-size: 13px;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.pill {
|
|
420
|
+
padding: 2px 10px;
|
|
421
|
+
border-radius: 99px;
|
|
422
|
+
background: var(--accent2);
|
|
423
|
+
border: 1px solid rgba(56, 139, 253, 0.3);
|
|
424
|
+
font-size: 12px;
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
color: var(--accent);
|
|
427
|
+
}
|
|
428
|
+
.pill.small {
|
|
429
|
+
padding: 1px 8px;
|
|
430
|
+
font-size: 11px;
|
|
431
|
+
}
|
|
432
|
+
.pill-func {
|
|
433
|
+
color: var(--accent);
|
|
434
|
+
background: var(--accent2);
|
|
435
|
+
}
|
|
436
|
+
.pill-block {
|
|
437
|
+
color: var(--good);
|
|
438
|
+
background: rgba(63, 185, 80, 0.15);
|
|
439
|
+
border-color: rgba(63, 185, 80, 0.3);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.group {
|
|
443
|
+
margin-bottom: 16px;
|
|
444
|
+
border: 1px solid var(--border);
|
|
445
|
+
border-radius: 6px;
|
|
446
|
+
background: var(--bg);
|
|
447
|
+
box-shadow: var(--shadow2);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.group-head {
|
|
451
|
+
display: flex;
|
|
452
|
+
justify-content: space-between;
|
|
453
|
+
align-items: center;
|
|
454
|
+
padding: 12px 16px;
|
|
455
|
+
background: var(--panel);
|
|
456
|
+
border-bottom: 1px solid var(--border);
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.group-left {
|
|
461
|
+
display: flex;
|
|
462
|
+
align-items: center;
|
|
463
|
+
gap: 12px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.group-title {
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
font-size: 14px;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.gkey {
|
|
472
|
+
font-family: var(--mono);
|
|
473
|
+
font-size: 12px;
|
|
474
|
+
color: var(--muted);
|
|
475
|
+
background: var(--panel2);
|
|
476
|
+
padding: 2px 6px;
|
|
477
|
+
border-radius: 4px;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.chev {
|
|
481
|
+
display: flex;
|
|
482
|
+
align-items: center;
|
|
483
|
+
justify-content: center;
|
|
484
|
+
width: 24px;
|
|
485
|
+
height: 24px;
|
|
486
|
+
border-radius: 4px;
|
|
487
|
+
border: 1px solid var(--border);
|
|
488
|
+
background: var(--bg);
|
|
489
|
+
color: var(--muted);
|
|
490
|
+
padding: 0;
|
|
491
|
+
}
|
|
492
|
+
.chev:hover {
|
|
493
|
+
color: var(--text);
|
|
494
|
+
border-color: var(--border2);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.items {
|
|
498
|
+
padding: 16px;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.item-pair {
|
|
502
|
+
display: grid;
|
|
503
|
+
grid-template-columns: 1fr 1fr;
|
|
504
|
+
gap: 16px;
|
|
505
|
+
margin-bottom: 16px;
|
|
506
|
+
}
|
|
507
|
+
.item-pair:last-child {
|
|
508
|
+
margin-bottom: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@media (max-width: 1000px) {
|
|
512
|
+
.item-pair {
|
|
513
|
+
grid-template-columns: 1fr;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.item {
|
|
518
|
+
border: 1px solid var(--border);
|
|
519
|
+
border-radius: 6px;
|
|
520
|
+
overflow: hidden;
|
|
521
|
+
display: flex;
|
|
522
|
+
flex-direction: column;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.item-head {
|
|
526
|
+
padding: 8px 12px;
|
|
527
|
+
background: var(--panel);
|
|
528
|
+
border-bottom: 1px solid var(--border);
|
|
529
|
+
font-size: 13px;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
color: var(--accent);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.item-file {
|
|
535
|
+
padding: 6px 12px;
|
|
536
|
+
background: var(--panel2);
|
|
537
|
+
border-bottom: 1px solid var(--border);
|
|
538
|
+
font-family: var(--mono);
|
|
539
|
+
font-size: 11px;
|
|
540
|
+
color: var(--muted);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.codebox {
|
|
544
|
+
margin: 0;
|
|
545
|
+
padding: 12px;
|
|
546
|
+
font-family: var(--mono);
|
|
547
|
+
font-size: 12px;
|
|
548
|
+
line-height: 1.5;
|
|
549
|
+
overflow: auto;
|
|
550
|
+
background: var(--bg);
|
|
551
|
+
flex: 1;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.empty {
|
|
555
|
+
padding: 60px 0;
|
|
556
|
+
display: flex;
|
|
557
|
+
justify-content: center;
|
|
558
|
+
}
|
|
559
|
+
.empty-card {
|
|
560
|
+
text-align: center;
|
|
561
|
+
padding: 40px;
|
|
562
|
+
background: var(--panel);
|
|
563
|
+
border: 1px solid var(--border);
|
|
564
|
+
border-radius: 12px;
|
|
565
|
+
max-width: 500px;
|
|
566
|
+
}
|
|
567
|
+
.empty-icon {
|
|
568
|
+
color: var(--good);
|
|
569
|
+
margin-bottom: 16px;
|
|
570
|
+
display: flex;
|
|
571
|
+
justify-content: center;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.footer {
|
|
575
|
+
margin-top: 60px;
|
|
576
|
+
text-align: center;
|
|
577
|
+
color: var(--muted);
|
|
578
|
+
font-size: 12px;
|
|
579
|
+
border-top: 1px solid var(--border);
|
|
580
|
+
padding-top: 24px;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
${pyg_dark}
|
|
584
|
+
${pyg_light}
|
|
585
|
+
</style>
|
|
586
|
+
</head>
|
|
587
|
+
|
|
588
|
+
<body>
|
|
589
|
+
<div class="topbar">
|
|
590
|
+
<div class="topbar-inner">
|
|
591
|
+
<div class="brand">
|
|
592
|
+
<h1>${title}</h1>
|
|
593
|
+
<div class="sub">v${version}</div>
|
|
594
|
+
</div>
|
|
595
|
+
<div class="top-actions">
|
|
596
|
+
<button class="btn" type="button" id="theme-toggle" title="Toggle theme">${icon_theme} Theme</button>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<div class="container">
|
|
602
|
+
${empty_state_html}
|
|
603
|
+
|
|
604
|
+
${func_section}
|
|
605
|
+
${block_section}
|
|
606
|
+
|
|
607
|
+
<div class="footer">Generated by CodeClone v${version}</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<script>
|
|
611
|
+
(() => {
|
|
612
|
+
const htmlEl = document.documentElement;
|
|
613
|
+
const btnTheme = document.getElementById("theme-toggle");
|
|
614
|
+
|
|
615
|
+
const stored = localStorage.getItem("codeclone_theme");
|
|
616
|
+
if (stored === "light" || stored === "dark") {
|
|
617
|
+
htmlEl.setAttribute("data-theme", stored);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
btnTheme?.addEventListener("click", () => {
|
|
621
|
+
const cur = htmlEl.getAttribute("data-theme") || "dark";
|
|
622
|
+
const next = cur === "dark" ? "light" : "dark";
|
|
623
|
+
htmlEl.setAttribute("data-theme", next);
|
|
624
|
+
localStorage.setItem("codeclone_theme", next);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Toggle group visibility via header click
|
|
628
|
+
document.querySelectorAll(".group-head").forEach((head) => {
|
|
629
|
+
head.addEventListener("click", (e) => {
|
|
630
|
+
if (e.target.closest("button")) return;
|
|
631
|
+
const btn = head.querySelector("[data-toggle-group]");
|
|
632
|
+
if (btn) btn.click();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
document.querySelectorAll("[data-toggle-group]").forEach((btn) => {
|
|
637
|
+
btn.addEventListener("click", () => {
|
|
638
|
+
const id = btn.getAttribute("data-toggle-group");
|
|
639
|
+
const body = document.getElementById("group-body-" + id);
|
|
640
|
+
if (!body) return;
|
|
641
|
+
|
|
642
|
+
const isHidden = body.style.display === "none";
|
|
643
|
+
body.style.display = isHidden ? "" : "none";
|
|
644
|
+
btn.style.transform = isHidden ? "rotate(0deg)" : "rotate(-90deg)";
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
function initSection(sectionId) {
|
|
649
|
+
const section = document.querySelector(`section[data-section='$${sectionId}']`);
|
|
650
|
+
if (!section) return;
|
|
651
|
+
|
|
652
|
+
const groups = Array.from(section.querySelectorAll(`.group[data-group='$${sectionId}']`));
|
|
653
|
+
const searchInput = document.getElementById(`search-$${sectionId}`);
|
|
654
|
+
const btnPrev = section.querySelector(`[data-prev='$${sectionId}']`);
|
|
655
|
+
const btnNext = section.querySelector(`[data-next='$${sectionId}']`);
|
|
656
|
+
const meta = section.querySelector(`[data-page-meta='$${sectionId}']`);
|
|
657
|
+
const selPageSize = section.querySelector(`[data-pagesize='$${sectionId}']`);
|
|
658
|
+
const btnClear = section.querySelector(`[data-clear='$${sectionId}']`);
|
|
659
|
+
const btnCollapseAll = section.querySelector(`[data-collapse-all='$${sectionId}']`);
|
|
660
|
+
const btnExpandAll = section.querySelector(`[data-expand-all='$${sectionId}']`);
|
|
661
|
+
const pill = section.querySelector(`[data-count-pill='$${sectionId}']`);
|
|
662
|
+
|
|
663
|
+
const state = {
|
|
664
|
+
q: "",
|
|
665
|
+
page: 1,
|
|
666
|
+
pageSize: parseInt(selPageSize?.value || "10", 10),
|
|
667
|
+
filtered: groups
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
function setGroupVisible(el, yes) {
|
|
671
|
+
el.style.display = yes ? "" : "none";
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function render() {
|
|
675
|
+
const total = state.filtered.length;
|
|
676
|
+
const pageSize = Math.max(1, state.pageSize);
|
|
677
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
678
|
+
state.page = Math.min(Math.max(1, state.page), pages);
|
|
679
|
+
|
|
680
|
+
const start = (state.page - 1) * pageSize;
|
|
681
|
+
const end = Math.min(total, start + pageSize);
|
|
682
|
+
|
|
683
|
+
groups.forEach(g => setGroupVisible(g, false));
|
|
684
|
+
state.filtered.slice(start, end).forEach(g => setGroupVisible(g, true));
|
|
685
|
+
|
|
686
|
+
if (meta) meta.textContent = `Page $${state.page} / $${pages} • $${total} groups`;
|
|
687
|
+
if (pill) pill.textContent = `$${total} groups`;
|
|
688
|
+
|
|
689
|
+
if (btnPrev) btnPrev.disabled = state.page <= 1;
|
|
690
|
+
if (btnNext) btnNext.disabled = state.page >= pages;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function applyFilter() {
|
|
694
|
+
const q = (state.q || "").trim().toLowerCase();
|
|
695
|
+
if (!q) {
|
|
696
|
+
state.filtered = groups;
|
|
697
|
+
} else {
|
|
698
|
+
state.filtered = groups.filter(g => {
|
|
699
|
+
const blob = g.getAttribute("data-search") || "";
|
|
700
|
+
return blob.indexOf(q) !== -1;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
state.page = 1;
|
|
704
|
+
render();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
searchInput?.addEventListener("input", (e) => {
|
|
708
|
+
state.q = e.target.value || "";
|
|
709
|
+
applyFilter();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
btnClear?.addEventListener("click", () => {
|
|
713
|
+
if (searchInput) searchInput.value = "";
|
|
714
|
+
state.q = "";
|
|
715
|
+
applyFilter();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
selPageSize?.addEventListener("change", () => {
|
|
719
|
+
state.pageSize = parseInt(selPageSize.value || "10", 10);
|
|
720
|
+
state.page = 1;
|
|
721
|
+
render();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
btnPrev?.addEventListener("click", () => {
|
|
725
|
+
state.page -= 1;
|
|
726
|
+
render();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
btnNext?.addEventListener("click", () => {
|
|
730
|
+
state.page += 1;
|
|
731
|
+
render();
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
btnCollapseAll?.addEventListener("click", () => {
|
|
735
|
+
section.querySelectorAll(".items").forEach(b => b.style.display = "none");
|
|
736
|
+
section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(-90deg)");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
btnExpandAll?.addEventListener("click", () => {
|
|
740
|
+
section.querySelectorAll(".items").forEach(b => b.style.display = "");
|
|
741
|
+
section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(0deg)");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
render();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
initSection("functions");
|
|
748
|
+
initSection("blocks");
|
|
749
|
+
})();
|
|
750
|
+
</script>
|
|
751
|
+
</body>
|
|
752
|
+
</html>
|
|
753
|
+
""")
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def build_html_report(
|
|
757
|
+
*,
|
|
758
|
+
func_groups: dict[str, list[dict[str, Any]]],
|
|
759
|
+
block_groups: dict[str, list[dict[str, Any]]],
|
|
760
|
+
title: str = "CodeClone Report",
|
|
761
|
+
context_lines: int = 3,
|
|
762
|
+
max_snippet_lines: int = 220,
|
|
763
|
+
) -> str:
|
|
764
|
+
file_cache = _FileCache()
|
|
765
|
+
|
|
766
|
+
func_sorted = sorted(func_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
767
|
+
block_sorted = sorted(block_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
768
|
+
|
|
769
|
+
has_any = bool(func_sorted) or bool(block_sorted)
|
|
770
|
+
|
|
771
|
+
# Pygments CSS (scoped). Use modern GitHub-like styles when available.
|
|
772
|
+
# We scope per theme to support toggle without reloading.
|
|
773
|
+
pyg_dark_raw = _pygments_css("github-dark")
|
|
774
|
+
if not pyg_dark_raw:
|
|
775
|
+
pyg_dark_raw = _pygments_css("monokai")
|
|
776
|
+
pyg_light_raw = _pygments_css("github-light")
|
|
777
|
+
if not pyg_light_raw:
|
|
778
|
+
pyg_light_raw = _pygments_css("friendly")
|
|
779
|
+
|
|
780
|
+
pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
|
|
781
|
+
pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
|
|
782
|
+
|
|
783
|
+
# ============================
|
|
784
|
+
# Icons (Inline SVG)
|
|
785
|
+
# ============================
|
|
786
|
+
ICON_SEARCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>'
|
|
787
|
+
ICON_X = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
|
|
788
|
+
ICON_CHEV_DOWN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>'
|
|
789
|
+
# ICON_CHEV_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
|
|
790
|
+
ICON_THEME = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>'
|
|
791
|
+
ICON_CHECK = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
|
792
|
+
ICON_PREV = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>'
|
|
793
|
+
ICON_NEXT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
|
|
794
|
+
|
|
795
|
+
# ----------------------------
|
|
796
|
+
# Section renderer
|
|
797
|
+
# ----------------------------
|
|
798
|
+
|
|
799
|
+
def render_section(
|
|
800
|
+
section_id: str,
|
|
801
|
+
section_title: str,
|
|
802
|
+
groups: list[tuple[str, list[dict[str, Any]]]],
|
|
803
|
+
pill_cls: str,
|
|
804
|
+
) -> str:
|
|
805
|
+
if not groups:
|
|
806
|
+
return ""
|
|
807
|
+
|
|
808
|
+
# build group DOM with data-search (for fast client-side search)
|
|
809
|
+
out: list[str] = [
|
|
810
|
+
f'<section id="{section_id}" class="section" data-section="{section_id}">',
|
|
811
|
+
'<div class="section-head">',
|
|
812
|
+
f"<h2>{_escape(section_title)} "
|
|
813
|
+
f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
|
|
814
|
+
f"""
|
|
815
|
+
<div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
|
|
816
|
+
<div class="toolbar-left">
|
|
817
|
+
<div class="search-wrap">
|
|
818
|
+
<span class="search-ico">{ICON_SEARCH}</span>
|
|
819
|
+
<input class="search" id="search-{section_id}" placeholder="Search..." autocomplete="off" />
|
|
820
|
+
<button class="btn ghost" type="button" data-clear="{section_id}" title="Clear search">{ICON_X}</button>
|
|
821
|
+
</div>
|
|
822
|
+
<div class="segmented">
|
|
823
|
+
<button class="btn seg" type="button" data-collapse-all="{section_id}">Collapse</button>
|
|
824
|
+
<button class="btn seg" type="button" data-expand-all="{section_id}">Expand</button>
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<div class="toolbar-right">
|
|
829
|
+
<div class="pager">
|
|
830
|
+
<button class="btn" type="button" data-prev="{section_id}">{ICON_PREV}</button>
|
|
831
|
+
<span class="page-meta" data-page-meta="{section_id}">Page 1</span>
|
|
832
|
+
<button class="btn" type="button" data-next="{section_id}">{ICON_NEXT}</button>
|
|
833
|
+
</div>
|
|
834
|
+
<select class="select" data-pagesize="{section_id}" title="Groups per page">
|
|
835
|
+
<option value="5">5 / page</option>
|
|
836
|
+
<option value="10" selected>10 / page</option>
|
|
837
|
+
<option value="20">20 / page</option>
|
|
838
|
+
<option value="50">50 / page</option>
|
|
839
|
+
</select>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
""",
|
|
843
|
+
"</div>", # section-head
|
|
844
|
+
'<div class="section-body">',
|
|
845
|
+
]
|
|
846
|
+
|
|
847
|
+
for idx, (gkey, items) in enumerate(groups, start=1):
|
|
848
|
+
search_parts: list[str] = [str(gkey)]
|
|
849
|
+
for it in items:
|
|
850
|
+
search_parts.append(str(it.get("qualname", "")))
|
|
851
|
+
search_parts.append(str(it.get("filepath", "")))
|
|
852
|
+
search_blob = " ".join(search_parts).lower()
|
|
853
|
+
search_blob_escaped = html.escape(search_blob, quote=True)
|
|
854
|
+
|
|
855
|
+
out.append(
|
|
856
|
+
f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
out.append(
|
|
860
|
+
f'<div class="group-head">'
|
|
861
|
+
f'<div class="group-left">'
|
|
862
|
+
f'<button class="chev" type="button" aria-label="Toggle group" data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
|
|
863
|
+
f'<div class="group-title">Group #{idx}</div>'
|
|
864
|
+
f'<span class="pill small {pill_cls}">{len(items)} items</span>'
|
|
865
|
+
f"</div>"
|
|
866
|
+
f'<div class="group-right">'
|
|
867
|
+
f'<code class="gkey">{_escape(gkey)}</code>'
|
|
868
|
+
f"</div>"
|
|
869
|
+
f"</div>"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
|
|
873
|
+
|
|
874
|
+
for a, b in pairwise(items):
|
|
875
|
+
out.append('<div class="item-pair">')
|
|
876
|
+
|
|
877
|
+
for item in (a, b):
|
|
878
|
+
snippet = _render_code_block(
|
|
879
|
+
filepath=item["filepath"],
|
|
880
|
+
start_line=int(item["start_line"]),
|
|
881
|
+
end_line=int(item["end_line"]),
|
|
882
|
+
file_cache=file_cache,
|
|
883
|
+
context=context_lines,
|
|
884
|
+
max_lines=max_snippet_lines,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
out.append(
|
|
888
|
+
'<div class="item">'
|
|
889
|
+
f'<div class="item-head">{_escape(item["qualname"])}</div>'
|
|
890
|
+
f'<div class="item-file">'
|
|
891
|
+
f"{_escape(item['filepath'])}:"
|
|
892
|
+
f"{item['start_line']}-{item['end_line']}"
|
|
893
|
+
f"</div>"
|
|
894
|
+
f"{snippet.code_html}"
|
|
895
|
+
"</div>"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
out.append("</div>") # item-pair
|
|
899
|
+
|
|
900
|
+
out.append("</div>") # items
|
|
901
|
+
out.append("</div>") # group
|
|
902
|
+
|
|
903
|
+
out.append("</div>") # section-body
|
|
904
|
+
out.append("</section>")
|
|
905
|
+
return "\n".join(out)
|
|
906
|
+
|
|
907
|
+
# ============================
|
|
908
|
+
# HTML Rendering
|
|
909
|
+
# ============================
|
|
910
|
+
|
|
911
|
+
empty_state_html = ""
|
|
912
|
+
if not has_any:
|
|
913
|
+
empty_state_html = f"""
|
|
914
|
+
<div class="empty">
|
|
915
|
+
<div class="empty-card">
|
|
916
|
+
<div class="empty-icon">{ICON_CHECK}</div>
|
|
917
|
+
<h2>No code clones detected</h2>
|
|
918
|
+
<p>No structural or block-level duplication was found above configured thresholds.</p>
|
|
919
|
+
<p class="muted">This usually indicates healthy abstraction boundaries.</p>
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
func_section = render_section("functions", "Function clones", func_sorted, "pill-func")
|
|
925
|
+
block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
|
|
926
|
+
|
|
927
|
+
return REPORT_TEMPLATE.substitute(
|
|
928
|
+
title=_escape(title),
|
|
929
|
+
version=__version__,
|
|
930
|
+
pyg_dark=pyg_dark,
|
|
931
|
+
pyg_light=pyg_light,
|
|
932
|
+
empty_state_html=empty_state_html,
|
|
933
|
+
func_section=func_section,
|
|
934
|
+
block_section=block_section,
|
|
935
|
+
icon_theme=ICON_THEME,
|
|
936
|
+
)
|