codeclone 1.2.0__py3-none-any.whl → 1.2.1__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 +1 -1
- codeclone/baseline.py +33 -7
- codeclone/blockhash.py +1 -1
- codeclone/blocks.py +4 -3
- codeclone/cache.py +151 -20
- codeclone/cfg.py +53 -128
- codeclone/cfg_model.py +47 -0
- codeclone/cli.py +308 -114
- codeclone/errors.py +27 -0
- codeclone/extractor.py +101 -24
- codeclone/html_report.py +196 -640
- codeclone/normalize.py +21 -14
- codeclone/py.typed +0 -0
- codeclone/report.py +23 -12
- codeclone/scanner.py +66 -3
- codeclone/templates.py +1262 -0
- {codeclone-1.2.0.dist-info → codeclone-1.2.1.dist-info}/METADATA +53 -35
- codeclone-1.2.1.dist-info/RECORD +23 -0
- codeclone-1.2.0.dist-info/RECORD +0 -19
- {codeclone-1.2.0.dist-info → codeclone-1.2.1.dist-info}/WHEEL +0 -0
- {codeclone-1.2.0.dist-info → codeclone-1.2.1.dist-info}/entry_points.txt +0 -0
- {codeclone-1.2.0.dist-info → codeclone-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {codeclone-1.2.0.dist-info → codeclone-1.2.1.dist-info}/top_level.txt +0 -0
codeclone/html_report.py
CHANGED
|
@@ -9,32 +9,35 @@ Licensed under the MIT License.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import html
|
|
12
|
+
import importlib
|
|
12
13
|
import itertools
|
|
14
|
+
from collections.abc import Iterable
|
|
13
15
|
from dataclasses import dataclass
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from typing import Any, Optional, Iterable
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import Any, NamedTuple, cast
|
|
17
18
|
|
|
18
19
|
from codeclone import __version__
|
|
20
|
+
from codeclone.errors import FileProcessingError
|
|
19
21
|
|
|
22
|
+
from .templates import FONT_CSS_URL, REPORT_TEMPLATE
|
|
20
23
|
|
|
21
|
-
# ============================
|
|
24
|
+
# ============================
|
|
22
25
|
# Pairwise
|
|
23
|
-
# ============================
|
|
26
|
+
# ============================
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
|
27
30
|
a, b = itertools.tee(iterable)
|
|
28
31
|
next(b, None)
|
|
29
|
-
return zip(a, b)
|
|
32
|
+
return zip(a, b, strict=False)
|
|
30
33
|
|
|
31
34
|
|
|
32
|
-
# ============================
|
|
35
|
+
# ============================
|
|
33
36
|
# Code snippet infrastructure
|
|
34
|
-
# ============================
|
|
37
|
+
# ============================
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
@dataclass
|
|
40
|
+
@dataclass(slots=True)
|
|
38
41
|
class _Snippet:
|
|
39
42
|
filepath: str
|
|
40
43
|
start_line: int
|
|
@@ -43,28 +46,79 @@ class _Snippet:
|
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
class _FileCache:
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
__slots__ = ("_get_lines_impl", "maxsize")
|
|
50
|
+
|
|
51
|
+
def __init__(self, maxsize: int = 128) -> None:
|
|
52
|
+
self.maxsize = maxsize
|
|
53
|
+
# Create a bound method with lru_cache
|
|
54
|
+
# We need to cache on the method to have instance-level caching if we wanted
|
|
55
|
+
# different caches per instance. But lru_cache on method actually caches
|
|
56
|
+
# on the function object (class level) if not careful,
|
|
57
|
+
# or we use a wrapper.
|
|
58
|
+
# However, for this script, we usually have one reporter.
|
|
59
|
+
# To be safe and cleaner, we can use a method that delegates to a cached
|
|
60
|
+
# function, OR just use lru_cache on a method (which requires 'self' to be
|
|
61
|
+
# hashable, which it is by default id).
|
|
62
|
+
# But 'self' changes if we create new instances.
|
|
63
|
+
# Let's use the audit's pattern: cache the implementation.
|
|
64
|
+
|
|
65
|
+
self._get_lines_impl = lru_cache(maxsize=maxsize)(self._read_file_range)
|
|
66
|
+
|
|
67
|
+
def _read_file_range(
|
|
68
|
+
self, filepath: str, start_line: int, end_line: int
|
|
69
|
+
) -> tuple[str, ...]:
|
|
70
|
+
if start_line < 1:
|
|
71
|
+
start_line = 1
|
|
72
|
+
if end_line < start_line:
|
|
73
|
+
return ()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
|
|
77
|
+
def _read_with_errors(errors: str) -> tuple[str, ...]:
|
|
78
|
+
lines: list[str] = []
|
|
79
|
+
with open(filepath, encoding="utf-8", errors=errors) as f:
|
|
80
|
+
for lineno, line in enumerate(f, start=1):
|
|
81
|
+
if lineno < start_line:
|
|
82
|
+
continue
|
|
83
|
+
if lineno > end_line:
|
|
84
|
+
break
|
|
85
|
+
lines.append(line.rstrip("\n"))
|
|
86
|
+
return tuple(lines)
|
|
48
87
|
|
|
49
|
-
def get_lines(self, filepath: str) -> list[str]:
|
|
50
|
-
if filepath not in self._lines:
|
|
51
88
|
try:
|
|
52
|
-
|
|
89
|
+
return _read_with_errors("strict")
|
|
53
90
|
except UnicodeDecodeError:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
return _read_with_errors("replace")
|
|
92
|
+
except OSError as e:
|
|
93
|
+
raise FileProcessingError(f"Cannot read {filepath}: {e}") from e
|
|
94
|
+
|
|
95
|
+
def get_lines_range(
|
|
96
|
+
self, filepath: str, start_line: int, end_line: int
|
|
97
|
+
) -> tuple[str, ...]:
|
|
98
|
+
return self._get_lines_impl(filepath, start_line, end_line)
|
|
57
99
|
|
|
100
|
+
class _CacheInfo(NamedTuple):
|
|
101
|
+
hits: int
|
|
102
|
+
misses: int
|
|
103
|
+
maxsize: int | None
|
|
104
|
+
currsize: int
|
|
58
105
|
|
|
59
|
-
def
|
|
106
|
+
def cache_info(self) -> _CacheInfo:
|
|
107
|
+
return cast(_FileCache._CacheInfo, self._get_lines_impl.cache_info())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _try_pygments(code: str) -> str | None:
|
|
60
111
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
112
|
+
pygments = importlib.import_module("pygments")
|
|
113
|
+
formatters = importlib.import_module("pygments.formatters")
|
|
114
|
+
lexers = importlib.import_module("pygments.lexers")
|
|
64
115
|
except Exception:
|
|
65
116
|
return None
|
|
66
117
|
|
|
67
|
-
|
|
118
|
+
highlight = pygments.highlight
|
|
119
|
+
formatter_cls = formatters.HtmlFormatter
|
|
120
|
+
lexer_cls = lexers.PythonLexer
|
|
121
|
+
result = highlight(code, lexer_cls(), formatter_cls(nowrap=True))
|
|
68
122
|
return result if isinstance(result, str) else None
|
|
69
123
|
|
|
70
124
|
|
|
@@ -74,21 +128,23 @@ def _pygments_css(style_name: str) -> str:
|
|
|
74
128
|
If Pygments is not available or style missing, returns "".
|
|
75
129
|
"""
|
|
76
130
|
try:
|
|
77
|
-
|
|
131
|
+
formatters = importlib.import_module("pygments.formatters")
|
|
78
132
|
except Exception:
|
|
79
133
|
return ""
|
|
80
134
|
|
|
81
135
|
try:
|
|
82
|
-
|
|
136
|
+
formatter_cls = formatters.HtmlFormatter
|
|
137
|
+
fmt = formatter_cls(style=style_name)
|
|
83
138
|
except Exception:
|
|
84
139
|
try:
|
|
85
|
-
fmt =
|
|
140
|
+
fmt = formatter_cls()
|
|
86
141
|
except Exception:
|
|
87
142
|
return ""
|
|
88
143
|
|
|
89
144
|
try:
|
|
90
145
|
# `.codebox` scope: pygments will emit selectors like `.codebox .k { ... }`
|
|
91
|
-
|
|
146
|
+
css = fmt.get_style_defs(".codebox")
|
|
147
|
+
return css if isinstance(css, str) else ""
|
|
92
148
|
except Exception:
|
|
93
149
|
return ""
|
|
94
150
|
|
|
@@ -104,11 +160,7 @@ def _prefix_css(css: str, prefix: str) -> str:
|
|
|
104
160
|
if not stripped:
|
|
105
161
|
out_lines.append(line)
|
|
106
162
|
continue
|
|
107
|
-
if (
|
|
108
|
-
stripped.startswith("/*")
|
|
109
|
-
or stripped.startswith("*")
|
|
110
|
-
or stripped.startswith("*/")
|
|
111
|
-
):
|
|
163
|
+
if stripped.startswith(("/*", "*", "*/")):
|
|
112
164
|
out_lines.append(line)
|
|
113
165
|
continue
|
|
114
166
|
# Selector lines usually end with `{
|
|
@@ -134,17 +186,16 @@ def _render_code_block(
|
|
|
134
186
|
context: int,
|
|
135
187
|
max_lines: int,
|
|
136
188
|
) -> _Snippet:
|
|
137
|
-
lines = file_cache.get_lines(filepath)
|
|
138
|
-
|
|
139
189
|
s = max(1, start_line - context)
|
|
140
|
-
e =
|
|
190
|
+
e = end_line + context
|
|
141
191
|
|
|
142
192
|
if e - s + 1 > max_lines:
|
|
143
193
|
e = s + max_lines - 1
|
|
144
194
|
|
|
195
|
+
lines = file_cache.get_lines_range(filepath, s, e)
|
|
196
|
+
|
|
145
197
|
numbered: list[tuple[bool, str]] = []
|
|
146
|
-
for lineno in
|
|
147
|
-
line = lines[lineno - 1]
|
|
198
|
+
for lineno, line in enumerate(lines, start=s):
|
|
148
199
|
hit = start_line <= lineno <= end_line
|
|
149
200
|
numbered.append((hit, f"{lineno:>5} | {line.rstrip()}"))
|
|
150
201
|
|
|
@@ -164,13 +215,13 @@ def _render_code_block(
|
|
|
164
215
|
filepath=filepath,
|
|
165
216
|
start_line=start_line,
|
|
166
217
|
end_line=end_line,
|
|
167
|
-
code_html=f'<
|
|
218
|
+
code_html=f'<div class="codebox"><pre><code>{body}</code></pre></div>',
|
|
168
219
|
)
|
|
169
220
|
|
|
170
221
|
|
|
171
|
-
# ============================
|
|
222
|
+
# ============================
|
|
172
223
|
# HTML report builder
|
|
173
|
-
# ============================
|
|
224
|
+
# ============================
|
|
174
225
|
|
|
175
226
|
|
|
176
227
|
def _escape(v: Any) -> str:
|
|
@@ -184,575 +235,6 @@ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
|
|
|
184
235
|
)
|
|
185
236
|
|
|
186
237
|
|
|
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
238
|
def build_html_report(
|
|
757
239
|
*,
|
|
758
240
|
func_groups: dict[str, list[dict[str, Any]]],
|
|
@@ -780,19 +262,69 @@ def build_html_report(
|
|
|
780
262
|
pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
|
|
781
263
|
pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
|
|
782
264
|
|
|
783
|
-
# ============================
|
|
265
|
+
# ============================
|
|
784
266
|
# Icons (Inline SVG)
|
|
785
|
-
# ============================
|
|
786
|
-
ICON_SEARCH =
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
267
|
+
# ============================
|
|
268
|
+
ICON_SEARCH = (
|
|
269
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
270
|
+
'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
|
|
271
|
+
'stroke-linejoin="round">'
|
|
272
|
+
'<circle cx="11" cy="11" r="8"></circle>'
|
|
273
|
+
'<line x1="21" y1="21" x2="16.65" y2="16.65"></line>'
|
|
274
|
+
"</svg>"
|
|
275
|
+
)
|
|
276
|
+
ICON_X = (
|
|
277
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
278
|
+
'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
|
|
279
|
+
'stroke-linejoin="round">'
|
|
280
|
+
'<line x1="18" y1="6" x2="6" y2="18"></line>'
|
|
281
|
+
'<line x1="6" y1="6" x2="18" y2="18"></line>'
|
|
282
|
+
"</svg>"
|
|
283
|
+
)
|
|
284
|
+
ICON_CHEV_DOWN = (
|
|
285
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
286
|
+
'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
|
|
287
|
+
'stroke-linejoin="round">'
|
|
288
|
+
'<polyline points="6 9 12 15 18 9"></polyline>'
|
|
289
|
+
"</svg>"
|
|
290
|
+
)
|
|
291
|
+
# ICON_CHEV_RIGHT = (
|
|
292
|
+
# '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
293
|
+
# 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" '
|
|
294
|
+
# 'stroke-linejoin="round">'
|
|
295
|
+
# '<polyline points="9 18 15 12 9 6"></polyline>'
|
|
296
|
+
# "</svg>"
|
|
297
|
+
# )
|
|
298
|
+
ICON_THEME = (
|
|
299
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
300
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
|
301
|
+
'stroke-linejoin="round">'
|
|
302
|
+
'<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>'
|
|
303
|
+
"</svg>"
|
|
304
|
+
)
|
|
305
|
+
ICON_CHECK = (
|
|
306
|
+
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none" '
|
|
307
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
|
308
|
+
'stroke-linejoin="round">'
|
|
309
|
+
'<polyline points="20 6 9 17 4 12"></polyline>'
|
|
310
|
+
"</svg>"
|
|
311
|
+
)
|
|
312
|
+
ICON_PREV = (
|
|
313
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
314
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
|
315
|
+
'stroke-linejoin="round">'
|
|
316
|
+
'<polyline points="15 18 9 12 15 6"></polyline>'
|
|
317
|
+
"</svg>"
|
|
318
|
+
)
|
|
319
|
+
ICON_NEXT = (
|
|
320
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
|
321
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
|
322
|
+
'stroke-linejoin="round">'
|
|
323
|
+
'<polyline points="9 18 15 12 9 6"></polyline>'
|
|
324
|
+
"</svg>"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# ----------------------------
|
|
796
328
|
# Section renderer
|
|
797
329
|
# ----------------------------
|
|
798
330
|
|
|
@@ -810,26 +342,43 @@ def build_html_report(
|
|
|
810
342
|
f'<section id="{section_id}" class="section" data-section="{section_id}">',
|
|
811
343
|
'<div class="section-head">',
|
|
812
344
|
f"<h2>{_escape(section_title)} "
|
|
813
|
-
f'<span class="pill {pill_cls}" data-count-pill="{section_id}">
|
|
345
|
+
f'<span class="pill {pill_cls}" data-count-pill="{section_id}">'
|
|
346
|
+
f"{len(groups)} groups</span></h2>",
|
|
814
347
|
f"""
|
|
815
|
-
<div class="section-toolbar"
|
|
348
|
+
<div class="section-toolbar"
|
|
349
|
+
role="toolbar"
|
|
350
|
+
aria-label="{_escape(section_title)} controls">
|
|
816
351
|
<div class="toolbar-left">
|
|
817
352
|
<div class="search-wrap">
|
|
818
353
|
<span class="search-ico">{ICON_SEARCH}</span>
|
|
819
|
-
<input class="search"
|
|
820
|
-
|
|
354
|
+
<input class="search"
|
|
355
|
+
id="search-{section_id}"
|
|
356
|
+
placeholder="Search..."
|
|
357
|
+
autocomplete="off" />
|
|
358
|
+
<button class="btn ghost"
|
|
359
|
+
type="button"
|
|
360
|
+
data-clear="{section_id}"
|
|
361
|
+
title="Clear search">{ICON_X}</button>
|
|
821
362
|
</div>
|
|
822
363
|
<div class="segmented">
|
|
823
|
-
<button class="btn seg"
|
|
824
|
-
|
|
364
|
+
<button class="btn seg"
|
|
365
|
+
type="button"
|
|
366
|
+
data-collapse-all="{section_id}">Collapse</button>
|
|
367
|
+
<button class="btn seg"
|
|
368
|
+
type="button"
|
|
369
|
+
data-expand-all="{section_id}">Expand</button>
|
|
825
370
|
</div>
|
|
826
371
|
</div>
|
|
827
372
|
|
|
828
373
|
<div class="toolbar-right">
|
|
829
374
|
<div class="pager">
|
|
830
|
-
<button class="btn"
|
|
375
|
+
<button class="btn"
|
|
376
|
+
type="button"
|
|
377
|
+
data-prev="{section_id}">{ICON_PREV}</button>
|
|
831
378
|
<span class="page-meta" data-page-meta="{section_id}">Page 1</span>
|
|
832
|
-
<button class="btn"
|
|
379
|
+
<button class="btn"
|
|
380
|
+
type="button"
|
|
381
|
+
data-next="{section_id}">{ICON_NEXT}</button>
|
|
833
382
|
</div>
|
|
834
383
|
<select class="select" data-pagesize="{section_id}" title="Groups per page">
|
|
835
384
|
<option value="5">5 / page</option>
|
|
@@ -853,20 +402,22 @@ def build_html_report(
|
|
|
853
402
|
search_blob_escaped = html.escape(search_blob, quote=True)
|
|
854
403
|
|
|
855
404
|
out.append(
|
|
856
|
-
f'<div class="group" data-group="{section_id}"
|
|
405
|
+
f'<div class="group" data-group="{section_id}" '
|
|
406
|
+
f'data-search="{search_blob_escaped}">'
|
|
857
407
|
)
|
|
858
408
|
|
|
859
409
|
out.append(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
f'<button class="chev" type="button" aria-label="Toggle group"
|
|
410
|
+
'<div class="group-head">'
|
|
411
|
+
'<div class="group-left">'
|
|
412
|
+
f'<button class="chev" type="button" aria-label="Toggle group" '
|
|
413
|
+
f'data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
|
|
863
414
|
f'<div class="group-title">Group #{idx}</div>'
|
|
864
415
|
f'<span class="pill small {pill_cls}">{len(items)} items</span>'
|
|
865
|
-
|
|
866
|
-
|
|
416
|
+
"</div>"
|
|
417
|
+
'<div class="group-right">'
|
|
867
418
|
f'<code class="gkey">{_escape(gkey)}</code>'
|
|
868
|
-
|
|
869
|
-
|
|
419
|
+
"</div>"
|
|
420
|
+
"</div>"
|
|
870
421
|
)
|
|
871
422
|
|
|
872
423
|
out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
|
|
@@ -904,9 +455,9 @@ def build_html_report(
|
|
|
904
455
|
out.append("</section>")
|
|
905
456
|
return "\n".join(out)
|
|
906
457
|
|
|
907
|
-
# ============================
|
|
458
|
+
# ============================
|
|
908
459
|
# HTML Rendering
|
|
909
|
-
# ============================
|
|
460
|
+
# ============================
|
|
910
461
|
|
|
911
462
|
empty_state_html = ""
|
|
912
463
|
if not has_any:
|
|
@@ -915,13 +466,17 @@ def build_html_report(
|
|
|
915
466
|
<div class="empty-card">
|
|
916
467
|
<div class="empty-icon">{ICON_CHECK}</div>
|
|
917
468
|
<h2>No code clones detected</h2>
|
|
918
|
-
<p>
|
|
469
|
+
<p>
|
|
470
|
+
No structural or block-level duplication was found above configured thresholds.
|
|
471
|
+
</p>
|
|
919
472
|
<p class="muted">This usually indicates healthy abstraction boundaries.</p>
|
|
920
473
|
</div>
|
|
921
474
|
</div>
|
|
922
475
|
"""
|
|
923
476
|
|
|
924
|
-
func_section = render_section(
|
|
477
|
+
func_section = render_section(
|
|
478
|
+
"functions", "Function clones", func_sorted, "pill-func"
|
|
479
|
+
)
|
|
925
480
|
block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
|
|
926
481
|
|
|
927
482
|
return REPORT_TEMPLATE.substitute(
|
|
@@ -933,4 +488,5 @@ def build_html_report(
|
|
|
933
488
|
func_section=func_section,
|
|
934
489
|
block_section=block_section,
|
|
935
490
|
icon_theme=ICON_THEME,
|
|
491
|
+
font_css_url=FONT_CSS_URL,
|
|
936
492
|
)
|