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