jscpd-rs 0.1.0

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/Cargo.lock +1323 -0
  3. package/Cargo.toml +54 -0
  4. package/LICENSE +21 -0
  5. package/README.md +372 -0
  6. package/docs/api-parity.md +49 -0
  7. package/docs/cloning-plan.md +281 -0
  8. package/docs/compat-baseline.md +535 -0
  9. package/docs/format-porting.md +86 -0
  10. package/docs/junior-task-template.md +62 -0
  11. package/docs/junior-workflow.md +87 -0
  12. package/docs/migrating-from-jscpd.md +193 -0
  13. package/docs/npm-release.md +116 -0
  14. package/docs/public-benchmark-suite.md +81 -0
  15. package/docs/release-checklist.md +200 -0
  16. package/docs/release-decisions.md +103 -0
  17. package/docs/release-readiness.md +51 -0
  18. package/docs/upstream-bugs.md +501 -0
  19. package/docs/upstream-issue-drafts.md +393 -0
  20. package/docs/user-guide.md +309 -0
  21. package/examples/dump_oxc_tokens.rs +112 -0
  22. package/examples/library_api.rs +42 -0
  23. package/npm/bin/jscpd-rs.js +6 -0
  24. package/npm/bin/jscpd-server.js +6 -0
  25. package/npm/lib/run-binary.js +68 -0
  26. package/npm/scripts/postinstall.js +50 -0
  27. package/package.json +53 -0
  28. package/skills/dry-refactoring/SKILL.md +63 -0
  29. package/skills/jscpd/SKILL.md +85 -0
  30. package/src/app.rs +512 -0
  31. package/src/bin/jscpd-server.rs +429 -0
  32. package/src/blame.rs +130 -0
  33. package/src/cli/config.rs +543 -0
  34. package/src/cli/parsing.rs +301 -0
  35. package/src/cli/tests.rs +543 -0
  36. package/src/cli.rs +671 -0
  37. package/src/detector/matching/secondary.rs +387 -0
  38. package/src/detector/matching.rs +274 -0
  39. package/src/detector/model.rs +190 -0
  40. package/src/detector/prepare.rs +71 -0
  41. package/src/detector/skip_local.rs +40 -0
  42. package/src/detector/statistics.rs +138 -0
  43. package/src/detector/store.rs +96 -0
  44. package/src/detector/tests.rs +238 -0
  45. package/src/detector.rs +265 -0
  46. package/src/files/discovery.rs +508 -0
  47. package/src/files/gitignore.rs +203 -0
  48. package/src/files/paths.rs +68 -0
  49. package/src/files/shebang.rs +106 -0
  50. package/src/files/tests.rs +523 -0
  51. package/src/files.rs +25 -0
  52. package/src/formats.rs +570 -0
  53. package/src/lib.rs +433 -0
  54. package/src/main.rs +26 -0
  55. package/src/report/ai.rs +125 -0
  56. package/src/report/badge.rs +238 -0
  57. package/src/report/console.rs +180 -0
  58. package/src/report/console_common.rs +37 -0
  59. package/src/report/console_full.rs +139 -0
  60. package/src/report/csv.rs +65 -0
  61. package/src/report/escape.rs +8 -0
  62. package/src/report/file_output.rs +28 -0
  63. package/src/report/html/assets.rs +47 -0
  64. package/src/report/html.rs +336 -0
  65. package/src/report/json.rs +119 -0
  66. package/src/report/markdown.rs +125 -0
  67. package/src/report/sarif.rs +302 -0
  68. package/src/report/silent.rs +22 -0
  69. package/src/report/source.rs +38 -0
  70. package/src/report/summary.rs +50 -0
  71. package/src/report/test_support.rs +133 -0
  72. package/src/report/threshold.rs +76 -0
  73. package/src/report/xcode.rs +90 -0
  74. package/src/report/xml.rs +119 -0
  75. package/src/report.rs +250 -0
  76. package/src/server/mcp.rs +942 -0
  77. package/src/server.rs +1081 -0
  78. package/src/tokenizer/apex.rs +97 -0
  79. package/src/tokenizer/blocks.rs +532 -0
  80. package/src/tokenizer/embedded.rs +106 -0
  81. package/src/tokenizer/generic.rs +511 -0
  82. package/src/tokenizer/hash.rs +27 -0
  83. package/src/tokenizer/ignore.rs +33 -0
  84. package/src/tokenizer/line_index.rs +33 -0
  85. package/src/tokenizer/markdown.rs +289 -0
  86. package/src/tokenizer/markup_attrs.rs +289 -0
  87. package/src/tokenizer/oxc/fallback.rs +275 -0
  88. package/src/tokenizer/oxc/jsx.rs +168 -0
  89. package/src/tokenizer/oxc/kind.rs +177 -0
  90. package/src/tokenizer/oxc/lexical.rs +67 -0
  91. package/src/tokenizer/oxc.rs +659 -0
  92. package/src/tokenizer/scan.rs +88 -0
  93. package/src/tokenizer/tap.rs +150 -0
  94. package/src/tokenizer/tests.rs +915 -0
  95. package/src/tokenizer.rs +328 -0
  96. package/src/verbose.rs +195 -0
@@ -0,0 +1,28 @@
1
+ use std::fs;
2
+ use std::path::Path;
3
+
4
+ use anyhow::{Context, Result};
5
+
6
+ use crate::cli::Options;
7
+
8
+ pub(super) fn ensure_output_dir(options: &Options) -> Result<()> {
9
+ fs::create_dir_all(&options.output)
10
+ .with_context(|| format!("failed to create output dir `{}`", options.output.display()))
11
+ }
12
+
13
+ pub(super) fn write_path(path: &Path, saved_prefix: &str, content: impl AsRef<[u8]>) -> Result<()> {
14
+ fs::write(path, content).with_context(|| format!("failed to write `{}`", path.display()))?;
15
+ println!("{saved_prefix} saved to {}", path.display());
16
+ Ok(())
17
+ }
18
+
19
+ pub(super) fn write_file_report(
20
+ options: &Options,
21
+ file_name: &str,
22
+ saved_prefix: &str,
23
+ content: impl AsRef<[u8]>,
24
+ ) -> Result<()> {
25
+ ensure_output_dir(options)?;
26
+ let path = options.output.join(file_name);
27
+ write_path(&path, saved_prefix, content)
28
+ }
@@ -0,0 +1,47 @@
1
+ pub(super) const TAILWIND_CSS: &str = r#"
2
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f4f6; color: #1f2937; }
3
+ .container { max-width: 1200px; margin: 0 auto; padding-left: 1rem; padding-right: 1rem; }
4
+ header { background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 0.12); padding: 1rem 0; }
5
+ main { background: #fff; margin-top: 2rem; margin-bottom: 2rem; padding: 1rem; box-shadow: 0 1px 3px rgb(0 0 0 / 0.12); border-radius: 0.25rem; }
6
+ h1 { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 600; }
7
+ h2 { margin: 0 0 1rem; font-size: 1.5rem; line-height: 2rem; font-weight: 600; color: #374151; }
8
+ h3 { margin: 0 0 0.5rem; font-size: 1.125rem; line-height: 1.75rem; font-weight: 600; }
9
+ section { margin-bottom: 2rem; }
10
+ table { width: 100%; border-collapse: collapse; }
11
+ th { background: #e5e7eb; color: #4b5563; font-size: 0.875rem; line-height: 1.25rem; padding: 0.75rem 1.5rem; text-align: left; text-transform: uppercase; }
12
+ td { border-bottom: 1px solid #e5e7eb; color: #1f2937; font-size: 0.875rem; line-height: 1.25rem; padding: 0.75rem 1.5rem; }
13
+ a { color: #2563eb; text-decoration: none; }
14
+ a:hover { text-decoration: underline; }
15
+ button { background: #6b7280; border: 0; border-radius: 0.25rem; color: #fff; cursor: pointer; font-size: 0.75rem; line-height: 1rem; margin-left: 0.5rem; padding: 0.125rem 0.25rem; }
16
+ pre { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 0.25rem; margin-top: 0.5rem; overflow: auto; padding: 1rem; }
17
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 0.875rem; line-height: 1.25rem; }
18
+ .dashboard-grid { display: grid; gap: 1rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
19
+ .card { border-radius: 0.25rem; padding: 1rem; text-align: center; }
20
+ .card span { display: block; font-size: 2.25rem; line-height: 2.5rem; font-weight: 700; }
21
+ .blue { background: #bfdbfe; color: #1e40af; }
22
+ .green { background: #bbf7d0; color: #166534; }
23
+ .yellow { background: #fef08a; color: #854d0e; }
24
+ .red { background: #fecaca; color: #991b1b; }
25
+ .clone { border-top: 1px solid #e5e7eb; padding: 1rem 0; }
26
+ .clone:first-child { border-top: 0; }
27
+ .clone p { margin: 0 0 0.5rem; color: #4b5563; }
28
+ .hidden { display: none; }
29
+ footer { margin-top: 60px; padding: 30px 0; border-top: 1px solid #e0e0e0; text-align: center; color: #666; }
30
+ @media (max-width: 800px) {
31
+ .dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
32
+ th, td { padding: 0.5rem; }
33
+ }
34
+ @media (max-width: 520px) {
35
+ .dashboard-grid { grid-template-columns: 1fr; }
36
+ main { margin-top: 1rem; }
37
+ }
38
+ "#;
39
+
40
+ pub(super) const PRISM_CSS: &str = r#"
41
+ pre[class*="language-"] { white-space: pre-wrap; word-break: break-word; }
42
+ code[class*="language-"] { color: #111827; }
43
+ "#;
44
+
45
+ pub(super) const PRISM_JS: &str = r#"
46
+ window.Prism = window.Prism || { highlightAll: function () {} };
47
+ "#;
@@ -0,0 +1,336 @@
1
+ use std::collections::BTreeSet;
2
+ use std::fs;
3
+ use std::path::Path;
4
+
5
+ use anyhow::{Context, Result};
6
+
7
+ use super::json;
8
+ use super::source::clone_fragment;
9
+ use crate::cli::Options;
10
+ use crate::detector::{CloneMatch, DetectionResult, StatisticRow};
11
+
12
+ mod assets;
13
+
14
+ const VERSION: &str = "4.2.4";
15
+
16
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
17
+ let destination = options.output.join("html");
18
+ fs::create_dir_all(destination.join("styles")).with_context(|| {
19
+ format!(
20
+ "failed to create html styles dir `{}`",
21
+ destination.join("styles").display()
22
+ )
23
+ })?;
24
+ fs::create_dir_all(destination.join("js")).with_context(|| {
25
+ format!(
26
+ "failed to create html scripts dir `{}`",
27
+ destination.join("js").display()
28
+ )
29
+ })?;
30
+
31
+ let index = HtmlReport::from_detection(result).to_string();
32
+ write_file(&destination.join("index.html"), index.as_bytes())?;
33
+ write_file(
34
+ &destination.join("jscpd-report.json"),
35
+ json::to_pretty_json(result)?.as_bytes(),
36
+ )?;
37
+ write_file(
38
+ &destination.join("styles").join("tailwind.css"),
39
+ assets::TAILWIND_CSS.as_bytes(),
40
+ )?;
41
+ write_file(
42
+ &destination.join("styles").join("prism.css"),
43
+ assets::PRISM_CSS.as_bytes(),
44
+ )?;
45
+ write_file(
46
+ &destination.join("js").join("prism.js"),
47
+ assets::PRISM_JS.as_bytes(),
48
+ )?;
49
+
50
+ println!(
51
+ "HTML report saved to {}",
52
+ display_directory_with_slash(&destination)
53
+ );
54
+ Ok(())
55
+ }
56
+
57
+ fn write_file(path: &Path, content: &[u8]) -> Result<()> {
58
+ fs::write(path, content).with_context(|| format!("failed to write `{}`", path.display()))
59
+ }
60
+
61
+ struct HtmlReport<'a> {
62
+ result: &'a DetectionResult,
63
+ formats: Vec<String>,
64
+ }
65
+
66
+ impl<'a> HtmlReport<'a> {
67
+ fn from_detection(result: &'a DetectionResult) -> Self {
68
+ let mut formats = result
69
+ .statistics
70
+ .formats
71
+ .keys()
72
+ .cloned()
73
+ .collect::<BTreeSet<_>>();
74
+ formats.extend(result.clones.iter().map(|clone| clone.format.clone()));
75
+ Self {
76
+ result,
77
+ formats: formats.into_iter().collect(),
78
+ }
79
+ }
80
+ }
81
+
82
+ impl std::fmt::Display for HtmlReport<'_> {
83
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84
+ let total = &self.result.statistics.total;
85
+
86
+ writeln!(f, "<!DOCTYPE html>")?;
87
+ writeln!(f, r#"<html lang="en">"#)?;
88
+ writeln!(f, "<head>")?;
89
+ writeln!(f, r#"<meta charset="UTF-8">"#)?;
90
+ writeln!(
91
+ f,
92
+ r#"<meta name="viewport" content="width=device-width, initial-scale=1.0">"#
93
+ )?;
94
+ writeln!(f, "<title>Copy/Paste Detector Report</title>")?;
95
+ writeln!(f, r#"<link href="styles/tailwind.css" rel="stylesheet">"#)?;
96
+ writeln!(f, r#"<link href="styles/prism.css" rel="stylesheet">"#)?;
97
+ writeln!(f, "</head>")?;
98
+ writeln!(f, "<body>")?;
99
+ writeln!(f, "<header><div class=\"container\">")?;
100
+ writeln!(f, "<h1>jscpd - copy/paste report</h1>")?;
101
+ writeln!(f, "</div></header>")?;
102
+ writeln!(f, "<main class=\"container\">")?;
103
+ write_dashboard(f, total)?;
104
+ write_formats(f, self)?;
105
+ write_clones(f, self)?;
106
+ writeln!(f, "</main>")?;
107
+ write_footer(f)?;
108
+ writeln!(f, r#"<script src="js/prism.js"></script>"#)?;
109
+ write_toggle_script(f)?;
110
+ writeln!(f, "</body>")?;
111
+ writeln!(f, "</html>")
112
+ }
113
+ }
114
+
115
+ fn write_dashboard(f: &mut std::fmt::Formatter<'_>, total: &StatisticRow) -> std::fmt::Result {
116
+ writeln!(f, r#"<section id="dashboard">"#)?;
117
+ writeln!(f, "<h2>Dashboard</h2>")?;
118
+ writeln!(f, r#"<div class="dashboard-grid">"#)?;
119
+ write_card(f, "blue", "Total Files", total.sources.to_string())?;
120
+ write_card(f, "green", "Total Lines of Code", total.lines.to_string())?;
121
+ write_card(f, "yellow", "Number of Clones", total.clones.to_string())?;
122
+ write_card(
123
+ f,
124
+ "red",
125
+ "Duplicated Lines",
126
+ format!("{} ({:.2}%)", total.duplicated_lines, total.percentage),
127
+ )?;
128
+ writeln!(f, "</div>")?;
129
+ writeln!(f, "</section>")
130
+ }
131
+
132
+ fn write_card(
133
+ f: &mut std::fmt::Formatter<'_>,
134
+ class_name: &str,
135
+ title: &str,
136
+ value: String,
137
+ ) -> std::fmt::Result {
138
+ writeln!(
139
+ f,
140
+ r#"<div class="card {class_name}"><h3>{}</h3><span>{}</span></div>"#,
141
+ escape_html(title),
142
+ escape_html(&value)
143
+ )
144
+ }
145
+
146
+ fn write_formats(f: &mut std::fmt::Formatter<'_>, report: &HtmlReport<'_>) -> std::fmt::Result {
147
+ writeln!(f, r#"<section id="formats">"#)?;
148
+ writeln!(f, "<h2>Formats with Duplications</h2>")?;
149
+ writeln!(f, "<table>")?;
150
+ writeln!(
151
+ f,
152
+ "<thead><tr><th>Format</th><th>Files</th><th>Lines</th><th>Clones</th><th>Duplicated Lines</th><th>Duplicated Tokens</th></tr></thead>"
153
+ )?;
154
+ writeln!(f, "<tbody>")?;
155
+ for format in &report.formats {
156
+ let Some(statistic) = report.result.statistics.formats.get(format) else {
157
+ continue;
158
+ };
159
+ let total = &statistic.total;
160
+ writeln!(
161
+ f,
162
+ r##"<tr><td><a href="#{}-clones">{}</a></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>"##,
163
+ escape_html(format),
164
+ escape_html(format),
165
+ total.sources,
166
+ total.lines,
167
+ total.clones,
168
+ total.duplicated_lines,
169
+ total.duplicated_tokens,
170
+ )?;
171
+ }
172
+ writeln!(f, "</tbody>")?;
173
+ writeln!(f, "</table>")?;
174
+ writeln!(f, "</section>")
175
+ }
176
+
177
+ fn write_clones(f: &mut std::fmt::Formatter<'_>, report: &HtmlReport<'_>) -> std::fmt::Result {
178
+ writeln!(f, r#"<section id="txt-clones">"#)?;
179
+ for format in &report.formats {
180
+ writeln!(f, r#"<a name="{}-clones"></a>"#, escape_html(format))?;
181
+ writeln!(f, "<h2>{}</h2>", escape_html(format))?;
182
+ writeln!(f, r#"<div class="clones">"#)?;
183
+ for (index, clone) in report
184
+ .result
185
+ .clones
186
+ .iter()
187
+ .enumerate()
188
+ .filter(|(_, clone)| clone.format == *format)
189
+ {
190
+ write_clone(f, report.result, clone, index)?;
191
+ }
192
+ writeln!(f, "</div>")?;
193
+ }
194
+ writeln!(f, "</section>")
195
+ }
196
+
197
+ fn write_clone(
198
+ f: &mut std::fmt::Formatter<'_>,
199
+ result: &DetectionResult,
200
+ clone: &CloneMatch,
201
+ index: usize,
202
+ ) -> std::fmt::Result {
203
+ writeln!(f, r#"<div class="clone">"#)?;
204
+ writeln!(
205
+ f,
206
+ "<p>{} (Line {}:{} - Line {}:{}), {} (Line {}:{} - Line {}:{})</p>",
207
+ escape_html(&clone.duplication_a.source_id),
208
+ clone.duplication_a.start.line,
209
+ clone.duplication_a.start.column,
210
+ clone.duplication_a.end.line,
211
+ clone.duplication_a.end.column,
212
+ escape_html(&clone.duplication_b.source_id),
213
+ clone.duplication_b.start.line,
214
+ clone.duplication_b.start.column,
215
+ clone.duplication_b.end.line,
216
+ clone.duplication_b.end.column,
217
+ )?;
218
+ writeln!(
219
+ f,
220
+ r#"<button id="expandBtn{index}" onclick="toggleCodeBlock('cloneGroup{index}', 'expandBtn{index}', 'collapseBtn{index}')">Show code</button>"#
221
+ )?;
222
+ writeln!(
223
+ f,
224
+ r#"<button class="hidden" id="collapseBtn{index}" onclick="toggleCodeBlock('cloneGroup{index}', 'expandBtn{index}', 'collapseBtn{index}')">Hide code</button>"#
225
+ )?;
226
+ writeln!(
227
+ f,
228
+ r#"<pre class="hidden" id="cloneGroup{index}"><code class="language-{}">{}</code></pre>"#,
229
+ escape_html(&clone.format),
230
+ escape_html(&clone_fragment(result, &clone.duplication_a))
231
+ )?;
232
+ writeln!(f, "</div>")
233
+ }
234
+
235
+ fn write_footer(f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236
+ writeln!(f, "<footer>")?;
237
+ writeln!(
238
+ f,
239
+ r#"<p>Generated by <a href="https://jscpd.dev" target="_blank">jscpd</a> v{VERSION} by <a href="https://github.com/kucherenko" target="_blank">Andrey Kucherenko</a></p>"#
240
+ )?;
241
+ writeln!(
242
+ f,
243
+ r#"<p><a href="https://www.npmjs.com/package/jscpd" target="_blank">npm package</a> &middot; Since 2013 &middot; <a href="https://opencollective.com/jscpd" target="_blank">Sponsor jscpd</a></p>"#
244
+ )?;
245
+ writeln!(f, "</footer>")
246
+ }
247
+
248
+ fn write_toggle_script(f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249
+ writeln!(f, "<script>")?;
250
+ writeln!(
251
+ f,
252
+ "function toggleCodeBlock(codeBlockId, expandBtnId, collapseBtnId) {{"
253
+ )?;
254
+ writeln!(
255
+ f,
256
+ " const codeBlock = document.getElementById(codeBlockId);"
257
+ )?;
258
+ writeln!(
259
+ f,
260
+ " const expandBtn = document.getElementById(expandBtnId);"
261
+ )?;
262
+ writeln!(
263
+ f,
264
+ " const collapseBtn = document.getElementById(collapseBtnId);"
265
+ )?;
266
+ writeln!(f, " codeBlock.classList.toggle('hidden');")?;
267
+ writeln!(f, " expandBtn.classList.toggle('hidden');")?;
268
+ writeln!(f, " collapseBtn.classList.toggle('hidden');")?;
269
+ writeln!(f, "}}")?;
270
+ writeln!(f, "</script>")
271
+ }
272
+
273
+ fn display_directory_with_slash(path: &Path) -> String {
274
+ let mut display = path.display().to_string();
275
+ if !display.ends_with(std::path::MAIN_SEPARATOR) {
276
+ display.push(std::path::MAIN_SEPARATOR);
277
+ }
278
+ display
279
+ }
280
+
281
+ fn escape_html(value: &str) -> String {
282
+ let mut escaped = String::with_capacity(value.len());
283
+ for character in value.chars() {
284
+ match character {
285
+ '&' => escaped.push_str("&amp;"),
286
+ '<' => escaped.push_str("&lt;"),
287
+ '>' => escaped.push_str("&gt;"),
288
+ '"' => escaped.push_str("&quot;"),
289
+ '\'' => escaped.push_str("&#39;"),
290
+ _ => escaped.push(character),
291
+ }
292
+ }
293
+ escaped
294
+ }
295
+
296
+ #[cfg(test)]
297
+ mod tests {
298
+ use super::*;
299
+ use crate::report::test_support::{
300
+ make_test_result_with_clone, write_test_report, write_test_report_output,
301
+ };
302
+
303
+ #[test]
304
+ fn html_report_writes_upstream_layout_files() {
305
+ let html = write_test_report("html", "html-report", &["html", "index.html"]);
306
+ let json = write_test_report("html", "html-report-json", &["html", "jscpd-report.json"]);
307
+
308
+ assert!(html.contains("<title>Copy/Paste Detector Report</title>"));
309
+ assert!(html.contains("jscpd - copy/paste report"));
310
+ assert!(html.contains("Formats with Duplications"));
311
+ assert!(html.contains("Show code"));
312
+ assert!(json.contains("\"duplicates\""));
313
+ assert!(json.contains("\"statistics\""));
314
+ }
315
+
316
+ #[test]
317
+ fn html_report_escapes_fragment_and_paths() {
318
+ let result = make_test_result_with_clone("src/a<&>.js", "src/b.js");
319
+ let html = HtmlReport::from_detection(&result).to_string();
320
+
321
+ assert!(html.contains("src/a&lt;&amp;&gt;.js"));
322
+ assert!(html.contains("alpha &lt;beta&gt; ]]&gt;"));
323
+ }
324
+
325
+ #[test]
326
+ fn html_report_writes_static_assets() {
327
+ let output = write_test_report_output("html", "html-assets");
328
+ let tailwind = output.join("html").join("styles").join("tailwind.css");
329
+ let prism_css = output.join("html").join("styles").join("prism.css");
330
+ let prism_js = output.join("html").join("js").join("prism.js");
331
+ let _ = std::fs::metadata(tailwind).unwrap();
332
+ let _ = std::fs::metadata(prism_css).unwrap();
333
+ let _ = std::fs::metadata(prism_js).unwrap();
334
+ let _ = std::fs::remove_dir_all(output);
335
+ }
336
+ }
@@ -0,0 +1,119 @@
1
+ use anyhow::Result;
2
+ use serde::Serialize;
3
+
4
+ use super::file_output::write_file_report;
5
+ use super::source::slice_range;
6
+ use crate::cli::Options;
7
+ use crate::detector::{BlamedLines, CloneMatch, DetectionResult, Statistics, clone_lines};
8
+
9
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
10
+ let json = to_pretty_json(result)?;
11
+ write_file_report(options, "jscpd-report.json", "JSON report", json)
12
+ }
13
+
14
+ pub(super) fn to_pretty_json(result: &DetectionResult) -> Result<String> {
15
+ Ok(serde_json::to_string_pretty(&JsonReport::from_detection(
16
+ result,
17
+ ))?)
18
+ }
19
+
20
+ #[derive(Serialize)]
21
+ struct JsonReport {
22
+ duplicates: Vec<JsonDuplicate>,
23
+ statistics: Statistics,
24
+ }
25
+
26
+ #[derive(Serialize)]
27
+ struct JsonDuplicate {
28
+ format: String,
29
+ lines: usize,
30
+ tokens: usize,
31
+ #[serde(rename = "firstFile")]
32
+ first_file: JsonFile,
33
+ #[serde(rename = "secondFile")]
34
+ second_file: JsonFile,
35
+ fragment: String,
36
+ }
37
+
38
+ #[derive(Serialize)]
39
+ struct JsonFile {
40
+ name: String,
41
+ start: usize,
42
+ end: usize,
43
+ #[serde(rename = "startLoc")]
44
+ start_loc: crate::tokenizer::Location,
45
+ #[serde(rename = "endLoc")]
46
+ end_loc: crate::tokenizer::Location,
47
+ #[serde(skip_serializing_if = "Option::is_none")]
48
+ blame: Option<BlamedLines>,
49
+ }
50
+
51
+ impl JsonReport {
52
+ fn from_detection(result: &DetectionResult) -> Self {
53
+ Self {
54
+ duplicates: result
55
+ .clones
56
+ .iter()
57
+ .map(|clone| JsonDuplicate::from_clone(clone, result))
58
+ .collect(),
59
+ statistics: result.statistics.clone(),
60
+ }
61
+ }
62
+ }
63
+
64
+ impl JsonDuplicate {
65
+ fn from_clone(clone: &CloneMatch, result: &DetectionResult) -> Self {
66
+ let fragment = result
67
+ .source_contents
68
+ .get(&clone.duplication_a.source_id)
69
+ .map(|content| slice_range(content, clone.duplication_a.range))
70
+ .unwrap_or_default();
71
+
72
+ Self {
73
+ format: clone.format.clone(),
74
+ lines: clone_lines(clone),
75
+ tokens: 0,
76
+ first_file: JsonFile {
77
+ name: clone.duplication_a.source_id.clone(),
78
+ start: clone.duplication_a.start.line,
79
+ end: clone.duplication_a.end.line,
80
+ start_loc: clone.duplication_a.start.clone(),
81
+ end_loc: clone.duplication_a.end.clone(),
82
+ blame: clone.duplication_a.blame.clone(),
83
+ },
84
+ second_file: JsonFile {
85
+ name: clone.duplication_b.source_id.clone(),
86
+ start: clone.duplication_b.start.line,
87
+ end: clone.duplication_b.end.line,
88
+ start_loc: clone.duplication_b.start.clone(),
89
+ end_loc: clone.duplication_b.end.clone(),
90
+ blame: clone.duplication_b.blame.clone(),
91
+ },
92
+ fragment,
93
+ }
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use crate::report::test_support::{make_test_result_with_clone, single_line_blame};
100
+
101
+ use super::to_pretty_json;
102
+
103
+ #[test]
104
+ fn json_report_includes_blame_when_present() {
105
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
106
+ result.clones[0].duplication_a.blame = Some(single_line_blame(
107
+ "2",
108
+ "abc123",
109
+ "Alice",
110
+ "2024-01-01 00:00:00 +0000",
111
+ ));
112
+
113
+ let json = to_pretty_json(&result).unwrap();
114
+
115
+ assert!(json.contains(r#""blame""#));
116
+ assert!(json.contains(r#""author": "Alice""#));
117
+ assert!(json.contains(r#""rev": "abc123""#));
118
+ }
119
+ }
@@ -0,0 +1,125 @@
1
+ use anyhow::Result;
2
+
3
+ use super::file_output::write_file_report;
4
+ use super::summary::{silent_summary, summary_rows};
5
+ use crate::cli::Options;
6
+ use crate::detector::DetectionResult;
7
+
8
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
9
+ let md = MarkdownReport::from_detection(result).to_string();
10
+ write_file_report(options, "jscpd-report.md", "Markdown report", md)
11
+ }
12
+
13
+ struct MarkdownReport {
14
+ summary_line: String,
15
+ rows: Vec<[String; 7]>,
16
+ }
17
+
18
+ impl MarkdownReport {
19
+ fn from_detection(result: &DetectionResult) -> Self {
20
+ let stats = &result.statistics;
21
+ let summary_line = format!("> {}", silent_summary(result));
22
+ let mut rows = summary_rows(stats);
23
+ if let Some(total) = rows.last_mut() {
24
+ for cell in total {
25
+ *cell = format!("**{cell}**");
26
+ }
27
+ }
28
+
29
+ Self { summary_line, rows }
30
+ }
31
+ }
32
+
33
+ impl std::fmt::Display for MarkdownReport {
34
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35
+ writeln!(f)?;
36
+ writeln!(f, "# Copy/paste detection report")?;
37
+ writeln!(f)?;
38
+ writeln!(f, "{}", self.summary_line)?;
39
+ writeln!(f)?;
40
+ let widths = markdown_column_widths(&self.rows);
41
+ for (row_idx, row) in self.rows.iter().enumerate() {
42
+ write_markdown_row(f, row, &widths)?;
43
+ if row_idx == 0 {
44
+ write_markdown_separator(f, &widths)?;
45
+ }
46
+ }
47
+ Ok(())
48
+ }
49
+ }
50
+
51
+ fn markdown_column_widths(rows: &[[String; 7]]) -> [usize; 7] {
52
+ let mut widths = [0usize; 7];
53
+ for row in rows {
54
+ for (idx, cell) in row.iter().enumerate() {
55
+ widths[idx] = widths[idx].max(cell.len());
56
+ }
57
+ }
58
+ widths
59
+ }
60
+
61
+ fn write_markdown_row(
62
+ f: &mut std::fmt::Formatter<'_>,
63
+ row: &[String; 7],
64
+ widths: &[usize; 7],
65
+ ) -> std::fmt::Result {
66
+ write!(f, "|")?;
67
+ for (idx, cell) in row.iter().enumerate() {
68
+ write!(f, " {cell:<width$} |", width = widths[idx])?;
69
+ }
70
+ writeln!(f)
71
+ }
72
+
73
+ fn write_markdown_separator(
74
+ f: &mut std::fmt::Formatter<'_>,
75
+ widths: &[usize; 7],
76
+ ) -> std::fmt::Result {
77
+ write!(f, "|")?;
78
+ for width in widths {
79
+ write!(f, " {:-<width$} |", "", width = *width)?;
80
+ }
81
+ writeln!(f)
82
+ }
83
+
84
+ #[cfg(test)]
85
+ mod tests {
86
+ use super::*;
87
+ use crate::report::test_support::write_test_report;
88
+
89
+ #[test]
90
+ fn write_reports_writes_markdown_report() {
91
+ let md = write_test_report("markdown", "markdown-report", &["jscpd-report.md"]);
92
+
93
+ assert!(md.starts_with("\n# Copy/paste detection report"));
94
+ assert!(md.contains("> Duplications detection:"));
95
+ }
96
+
97
+ #[test]
98
+ fn markdown_report_matches_upstream_summary_shape() {
99
+ let result = crate::detector::DetectionResult {
100
+ clones: Vec::new(),
101
+ skipped_clones: Vec::new(),
102
+ statistics: crate::report::test_support::make_test_statistics(),
103
+ sources: Vec::new(),
104
+ source_contents: std::collections::HashMap::new(),
105
+ };
106
+ let md = MarkdownReport::from_detection(&result).to_string();
107
+
108
+ assert_eq!(
109
+ md,
110
+ [
111
+ "",
112
+ "# Copy/paste detection report",
113
+ "",
114
+ "> Duplications detection: Found 0 exact clones with 5(25%) duplicated lines in 2 (1 formats) files.",
115
+ "",
116
+ "| Format | Files analyzed | Total lines | Total tokens | Clones found | Duplicated lines | Duplicated tokens |",
117
+ "| ---------- | -------------- | ----------- | ------------ | ------------ | ---------------- | ----------------- |",
118
+ "| javascript | 2 | 20 | 100 | 1 | 5 (25%) | 30 (30%) |",
119
+ "| **Total:** | **2** | **20** | **100** | **1** | **5 (25%)** | **30 (30%)** |",
120
+ "",
121
+ ]
122
+ .join("\n")
123
+ );
124
+ }
125
+ }