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,238 @@
1
+ use std::path::PathBuf;
2
+
3
+ use anyhow::Result;
4
+ use serde_json::{Map, Value};
5
+
6
+ use super::escape::escape_xml;
7
+ use super::file_output::{ensure_output_dir, write_path};
8
+ use crate::cli::Options;
9
+ use crate::detector::DetectionResult;
10
+
11
+ pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
12
+ ensure_output_dir(options)?;
13
+ let path = badge_output_path(options);
14
+ let badge = BadgeReport::from_detection(result, options).to_string();
15
+ write_path(&path, "Badge", badge)
16
+ }
17
+
18
+ struct BadgeReport {
19
+ subject: String,
20
+ status: String,
21
+ color: String,
22
+ }
23
+
24
+ impl BadgeReport {
25
+ fn from_detection(result: &DetectionResult, options: &Options) -> Self {
26
+ let badge_options = badge_reporter_options(options);
27
+ Self {
28
+ subject: badge_option_str(badge_options, "subject")
29
+ .unwrap_or("Copy/Paste")
30
+ .to_string(),
31
+ status: badge_option_str(badge_options, "status")
32
+ .map(str::to_string)
33
+ .unwrap_or_else(|| format!("{}%", result.statistics.total.percentage)),
34
+ color: badge_option_color(badge_options)
35
+ .unwrap_or_else(|| badge_color(result, options).to_string()),
36
+ }
37
+ }
38
+ }
39
+
40
+ impl std::fmt::Display for BadgeReport {
41
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42
+ let subject_width = text_width(&self.subject);
43
+ let status_width = text_width(&self.status);
44
+ let subject_rect_width = subject_width + 100;
45
+ let status_rect_width = status_width + 100;
46
+ let total_width = subject_rect_width + status_rect_width;
47
+ let display_width = total_width as f64 / 10.0;
48
+ let subject_text_x = 50;
49
+ let status_text_x = subject_rect_width + 45;
50
+ let subject_shadow_x = subject_text_x + 10;
51
+ let status_shadow_x = status_text_x + 10;
52
+ let subject = escape_xml(&self.subject);
53
+ let status = escape_xml(&self.status);
54
+ let color = escape_xml(&self.color);
55
+
56
+ write!(
57
+ f,
58
+ "<svg width=\"{display_width:.1}\" height=\"20\" viewBox=\"0 0 {total_width} 200\" xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"{subject}: {status}\">\n <title>{subject}: {status}</title>\n <linearGradient id=\"g\" x2=\"0\" y2=\"100%\">\n <stop offset=\"0\" stop-opacity=\".1\" stop-color=\"#EEE\"/>\n <stop offset=\"1\" stop-opacity=\".1\"/>\n </linearGradient>\n <mask id=\"m\"><rect width=\"{total_width}\" height=\"200\" rx=\"30\" fill=\"#FFF\"/></mask>\n <g mask=\"url(#m)\">\n <rect width=\"{subject_rect_width}\" height=\"200\" fill=\"#555\"/>\n <rect width=\"{status_rect_width}\" height=\"200\" fill=\"{}\" x=\"{subject_rect_width}\"/>\n <rect width=\"{total_width}\" height=\"200\" fill=\"url(#g)\"/>\n </g>\n <g aria-hidden=\"true\" fill=\"#fff\" text-anchor=\"start\" font-family=\"Verdana,DejaVu Sans,sans-serif\" font-size=\"110\">\n <text x=\"{subject_shadow_x}\" y=\"148\" textLength=\"{subject_width}\" fill=\"#000\" opacity=\"0.25\">{subject}</text>\n <text x=\"{subject_text_x}\" y=\"138\" textLength=\"{subject_width}\">{subject}</text>\n <text x=\"{status_shadow_x}\" y=\"148\" textLength=\"{status_width}\" fill=\"#000\" opacity=\"0.25\">{status}</text>\n <text x=\"{status_text_x}\" y=\"138\" textLength=\"{status_width}\">{status}</text>\n </g>\n \n</svg>",
59
+ color
60
+ )
61
+ }
62
+ }
63
+
64
+ fn badge_output_path(options: &Options) -> PathBuf {
65
+ badge_option_str(badge_reporter_options(options), "path")
66
+ .map(PathBuf::from)
67
+ .unwrap_or_else(|| options.output.join("jscpd-badge.svg"))
68
+ }
69
+
70
+ fn badge_reporter_options(options: &Options) -> Option<&Map<String, Value>> {
71
+ options
72
+ .reporters_options
73
+ .get("badge")
74
+ .and_then(Value::as_object)
75
+ }
76
+
77
+ fn badge_option_str<'a>(
78
+ badge_options: Option<&'a Map<String, Value>>,
79
+ key: &str,
80
+ ) -> Option<&'a str> {
81
+ badge_options?.get(key)?.as_str()
82
+ }
83
+
84
+ fn badge_option_color(badge_options: Option<&Map<String, Value>>) -> Option<String> {
85
+ let color = badge_option_str(badge_options, "color")?;
86
+ Some(
87
+ match color {
88
+ "green" => "#3C1",
89
+ "blue" => "#08C",
90
+ "red" => "#E43",
91
+ "yellow" => "#DB1",
92
+ "orange" => "#F73",
93
+ "purple" => "#94E",
94
+ "pink" => "#E5B",
95
+ "grey" | "gray" => "#999",
96
+ "cyan" => "#1BC",
97
+ "black" => "#2A2A2A",
98
+ _ => return Some(format!("#{color}")),
99
+ }
100
+ .to_string(),
101
+ )
102
+ }
103
+
104
+ fn badge_color(result: &DetectionResult, options: &Options) -> &'static str {
105
+ match options.threshold {
106
+ Some(threshold) if result.statistics.total.percentage < threshold => "#3C1",
107
+ Some(_) => "#E43",
108
+ None => "#999",
109
+ }
110
+ }
111
+
112
+ fn text_width(value: &str) -> usize {
113
+ value.chars().map(char_width).sum::<usize>() + 26
114
+ }
115
+
116
+ fn char_width(value: char) -> usize {
117
+ match value {
118
+ 'A'..='Z' => 73,
119
+ 'a'..='z' => 63,
120
+ '0'..='9' => 61,
121
+ '/' => 38,
122
+ '.' => 31,
123
+ '%' => 88,
124
+ ':' => 28,
125
+ ' ' => 35,
126
+ _ => 63,
127
+ }
128
+ }
129
+
130
+ #[cfg(test)]
131
+ mod tests {
132
+ use super::*;
133
+ use crate::report::test_support::{make_test_result_with_clone, write_test_report};
134
+ use crate::report::write_reports;
135
+
136
+ #[test]
137
+ fn badge_color_matches_upstream_threshold_rules() {
138
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
139
+ result.statistics.total.percentage = 25.0;
140
+
141
+ assert_eq!(badge_color(&result, &Options::default()), "#999");
142
+ assert_eq!(
143
+ badge_color(
144
+ &result,
145
+ &Options {
146
+ threshold: Some(25.1),
147
+ ..Options::default()
148
+ }
149
+ ),
150
+ "#3C1"
151
+ );
152
+ assert_eq!(
153
+ badge_color(
154
+ &result,
155
+ &Options {
156
+ threshold: Some(25.0),
157
+ ..Options::default()
158
+ }
159
+ ),
160
+ "#E43"
161
+ );
162
+ }
163
+
164
+ #[test]
165
+ fn badge_report_matches_upstream_default_shape() {
166
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
167
+ let badge = BadgeReport::from_detection(&result, &Options::default()).to_string();
168
+
169
+ assert!(badge.starts_with("<svg "));
170
+ assert!(badge.contains(r#"role="img" aria-label="Copy/Paste: 25%""#));
171
+ assert!(badge.contains("<title>Copy/Paste: 25%</title>"));
172
+ assert!(badge.contains(r##"fill="#999""##));
173
+ assert!(badge.contains(">Copy/Paste</text>"));
174
+ assert!(badge.contains(">25%</text>"));
175
+ }
176
+
177
+ #[test]
178
+ fn badge_report_uses_reporter_options_like_upstream() {
179
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
180
+ result.statistics.total.percentage = 25.0;
181
+ let options = Options {
182
+ reporters_options: serde_json::json!({
183
+ "badge": {
184
+ "subject": "Duplicates",
185
+ "status": "blocked",
186
+ "color": "purple"
187
+ }
188
+ })
189
+ .as_object()
190
+ .unwrap()
191
+ .clone(),
192
+ ..Options::default()
193
+ };
194
+
195
+ let badge = BadgeReport::from_detection(&result, &options).to_string();
196
+
197
+ assert!(badge.contains(r#"aria-label="Duplicates: blocked""#));
198
+ assert!(badge.contains(r##"fill="#94E""##));
199
+ assert!(badge.contains(">Duplicates</text>"));
200
+ assert!(badge.contains(">blocked</text>"));
201
+ }
202
+
203
+ #[test]
204
+ fn write_reports_writes_badge_report() {
205
+ let svg = write_test_report("badge", "badge-report", &["jscpd-badge.svg"]);
206
+
207
+ assert!(svg.contains("Copy/Paste"));
208
+ assert!(svg.contains("25%"));
209
+ }
210
+
211
+ #[test]
212
+ fn write_reports_uses_badge_reporter_path_option() {
213
+ let output = crate::report::test_support::temp_output("badge-path");
214
+ let badge_path = output.join("custom-badge.svg");
215
+ let options = Options {
216
+ output: output.clone(),
217
+ reporters: vec!["badge".to_string()],
218
+ reporters_options: serde_json::json!({
219
+ "badge": {
220
+ "path": badge_path
221
+ }
222
+ })
223
+ .as_object()
224
+ .unwrap()
225
+ .clone(),
226
+ silent: true,
227
+ ..Options::default()
228
+ };
229
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
230
+
231
+ write_reports(&result, &options).unwrap();
232
+ let svg = std::fs::read_to_string(&badge_path).unwrap();
233
+ let _ = std::fs::remove_dir_all(output);
234
+
235
+ assert!(svg.contains("Copy/Paste"));
236
+ assert!(svg.contains("25%"));
237
+ }
238
+ }
@@ -0,0 +1,180 @@
1
+ use super::console_common::{BOLD, GREY, RED, RESET_BOLD, RESET_COLOR};
2
+ use super::summary::statistic_to_summary_row;
3
+ use crate::cli::Options;
4
+ use crate::detector::DetectionResult;
5
+
6
+ const HEADERS: [&str; 7] = [
7
+ "Format",
8
+ "Files analyzed",
9
+ "Total lines",
10
+ "Total tokens",
11
+ "Clones found",
12
+ "Duplicated lines",
13
+ "Duplicated tokens",
14
+ ];
15
+
16
+ pub(super) fn write(result: &DetectionResult, options: &Options) {
17
+ print!("{}", console_report(result, options));
18
+ }
19
+
20
+ fn console_report(result: &DetectionResult, _options: &Options) -> String {
21
+ let mut output = String::new();
22
+ output.push_str(&summary_table(result));
23
+ output.push_str(&format!(
24
+ "{GREY}Found {} clones.{RESET_COLOR}\n",
25
+ result.clones.len()
26
+ ));
27
+ output
28
+ }
29
+
30
+ fn summary_table(result: &DetectionResult) -> String {
31
+ let mut rows = Vec::new();
32
+ let mut formats = result.statistics.formats.iter().collect::<Vec<_>>();
33
+ formats.sort_by_key(|(format, _)| *format);
34
+
35
+ for (format, statistic) in formats {
36
+ rows.push(statistic_to_summary_row(format, &statistic.total));
37
+ }
38
+ rows.push(statistic_to_summary_row("Total:", &result.statistics.total));
39
+
40
+ let widths = column_widths(&rows);
41
+ let mut table = String::new();
42
+ table.push_str(&divider('┌', '┬', '┐', &widths));
43
+ table.push_str(&row(
44
+ &HEADERS.map(str::to_string),
45
+ &widths,
46
+ RowStyle::Header,
47
+ ));
48
+ table.push_str(&divider('├', '┼', '┤', &widths));
49
+ for (index, row_cells) in rows.iter().enumerate() {
50
+ let is_total = index + 1 == rows.len();
51
+ if is_total && index > 0 {
52
+ table.push_str(&divider('├', '┼', '┤', &widths));
53
+ }
54
+ table.push_str(&row(
55
+ row_cells,
56
+ &widths,
57
+ if is_total {
58
+ RowStyle::Total
59
+ } else {
60
+ RowStyle::Body
61
+ },
62
+ ));
63
+ }
64
+ table.push_str(&divider('└', '┴', '┘', &widths));
65
+ table
66
+ }
67
+
68
+ fn column_widths(rows: &[[String; 7]]) -> [usize; 7] {
69
+ let mut widths = HEADERS.map(str::len);
70
+ for row in rows {
71
+ for (idx, cell) in row.iter().enumerate() {
72
+ widths[idx] = widths[idx].max(cell.len());
73
+ }
74
+ }
75
+ widths
76
+ }
77
+
78
+ fn divider(left: char, middle: char, right: char, widths: &[usize; 7]) -> String {
79
+ let mut line = String::new();
80
+ line.push_str(GREY);
81
+ line.push(left);
82
+ for (idx, width) in widths.iter().enumerate() {
83
+ line.push_str(&"─".repeat(width + 2));
84
+ if idx + 1 == widths.len() {
85
+ line.push(right);
86
+ } else {
87
+ line.push(middle);
88
+ }
89
+ }
90
+ line.push_str(RESET_COLOR);
91
+ line.push('\n');
92
+ line
93
+ }
94
+
95
+ #[derive(Clone, Copy)]
96
+ enum RowStyle {
97
+ Header,
98
+ Body,
99
+ Total,
100
+ }
101
+
102
+ fn row(cells: &[String; 7], widths: &[usize; 7], style: RowStyle) -> String {
103
+ let mut line = String::new();
104
+ line.push_str(GREY);
105
+ line.push('│');
106
+ line.push_str(RESET_COLOR);
107
+
108
+ for (idx, cell) in cells.iter().enumerate() {
109
+ let padded = format!(" {cell:<width$} ", width = widths[idx]);
110
+ match style {
111
+ RowStyle::Header => {
112
+ line.push_str(RED);
113
+ line.push_str(&padded);
114
+ line.push_str(RESET_COLOR);
115
+ }
116
+ RowStyle::Total if idx == 0 => {
117
+ line.push_str(BOLD);
118
+ line.push_str(&padded);
119
+ line.push_str(RESET_BOLD);
120
+ }
121
+ RowStyle::Total | RowStyle::Body => line.push_str(&padded),
122
+ }
123
+ line.push_str(GREY);
124
+ line.push('│');
125
+ line.push_str(RESET_COLOR);
126
+ }
127
+
128
+ line.push('\n');
129
+ line
130
+ }
131
+
132
+ #[cfg(test)]
133
+ mod tests {
134
+ use super::*;
135
+ use crate::report::test_support::{make_test_result_with_clone, make_test_statistics};
136
+
137
+ #[test]
138
+ fn console_report_matches_upstream_shape() {
139
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
140
+ let report = console_report(&result, &Options::default());
141
+
142
+ assert!(!report.contains("Clone found (javascript):"));
143
+ assert!(report.contains("Format"));
144
+ assert!(report.contains("Files analyzed"));
145
+ assert!(report.contains("javascript"));
146
+ assert!(report.contains("Total:"));
147
+ assert!(report.contains("Found 1 clones."));
148
+ }
149
+
150
+ #[test]
151
+ fn console_report_includes_zero_clone_table() {
152
+ let mut statistics = make_test_statistics();
153
+ statistics.total.clones = 0;
154
+ statistics.total.duplicated_lines = 0;
155
+ statistics.total.duplicated_tokens = 0;
156
+ statistics.total.percentage = 0.0;
157
+ statistics.total.percentage_tokens = 0.0;
158
+ for statistic in statistics.formats.values_mut() {
159
+ statistic.total.clones = 0;
160
+ statistic.total.duplicated_lines = 0;
161
+ statistic.total.duplicated_tokens = 0;
162
+ statistic.total.percentage = 0.0;
163
+ statistic.total.percentage_tokens = 0.0;
164
+ }
165
+ let result = DetectionResult {
166
+ clones: Vec::new(),
167
+ skipped_clones: Vec::new(),
168
+ statistics,
169
+ sources: Vec::new(),
170
+ source_contents: std::collections::HashMap::new(),
171
+ };
172
+
173
+ let report = console_report(&result, &Options::default());
174
+
175
+ assert!(!report.contains("Clone found ("));
176
+ assert!(report.contains("javascript"));
177
+ assert!(report.contains("0 (0%)"));
178
+ assert!(report.contains("Found 0 clones."));
179
+ }
180
+ }
@@ -0,0 +1,37 @@
1
+ use super::source::source_location;
2
+ use crate::cli::Options;
3
+ use crate::detector::CloneMatch;
4
+
5
+ pub(super) const BOLD: &str = "\x1b[1m";
6
+ pub(super) const BOLD_GREEN: &str = "\x1b[1m\x1b[32m";
7
+ pub(super) const GREY: &str = "\x1b[90m";
8
+ pub(super) const RED: &str = "\x1b[31m";
9
+ pub(super) const RESET_BOLD: &str = "\x1b[22m";
10
+ pub(super) const RESET_COLOR: &str = "\x1b[39m";
11
+
12
+ pub(super) fn clone_header(clone: &CloneMatch, _options: &Options) -> String {
13
+ let path_a = colored_path(&clone.duplication_a.source_id);
14
+ let path_b = colored_path(&clone.duplication_b.source_id);
15
+ format!(
16
+ "Clone found ({}):\n - {} [{}] ({} lines, {} tokens)\n {} [{}]\n",
17
+ clone.format,
18
+ path_a,
19
+ source_location(&clone.duplication_a.start, &clone.duplication_a.end),
20
+ clone
21
+ .duplication_a
22
+ .end
23
+ .line
24
+ .saturating_sub(clone.duplication_a.start.line),
25
+ clone
26
+ .duplication_a
27
+ .end
28
+ .position
29
+ .saturating_sub(clone.duplication_a.start.position),
30
+ path_b,
31
+ source_location(&clone.duplication_b.start, &clone.duplication_b.end),
32
+ )
33
+ }
34
+
35
+ fn colored_path(path: &str) -> String {
36
+ format!("{BOLD_GREEN}{path}{RESET_COLOR}{RESET_BOLD}")
37
+ }
@@ -0,0 +1,139 @@
1
+ use super::console_common::{GREY, RESET_COLOR, clone_header};
2
+ use super::source::clone_fragment;
3
+ use crate::cli::Options;
4
+ use crate::detector::{CloneMatch, DetectionResult};
5
+
6
+ pub(super) fn write(result: &DetectionResult, options: &Options) {
7
+ print!("{}", console_full_report(result, options));
8
+ }
9
+
10
+ fn console_full_report(result: &DetectionResult, options: &Options) -> String {
11
+ let mut output = String::new();
12
+ for clone in &result.clones {
13
+ output.push_str(&clone_header(clone, options));
14
+ output.push('\n');
15
+ output.push_str(&fragment_table(result, clone));
16
+ output.push('\n');
17
+ }
18
+ output.push_str(&format!(
19
+ "{GREY}Found {} clones.{RESET_COLOR}\n",
20
+ result.clones.len()
21
+ ));
22
+ output
23
+ }
24
+
25
+ fn fragment_table(result: &DetectionResult, clone: &CloneMatch) -> String {
26
+ let fragment = clone_fragment(result, &clone.duplication_a);
27
+ let lines = fragment.split('\n').collect::<Vec<_>>();
28
+ let max_line_a = clone.duplication_a.start.line + lines.len().saturating_sub(1);
29
+ let max_line_b = clone.duplication_b.start.line + lines.len().saturating_sub(1);
30
+ let width_a = max_line_a.to_string().len();
31
+ let width_b = max_line_b.to_string().len();
32
+ let mut output = String::new();
33
+
34
+ for (idx, line) in lines.iter().enumerate() {
35
+ if idx > 0 {
36
+ output.push('\n');
37
+ }
38
+ let line_a = clone.duplication_a.start.line + idx;
39
+ let line_b = clone.duplication_b.start.line + idx;
40
+ if let (Some(blame_a), Some(blame_b)) =
41
+ (&clone.duplication_a.blame, &clone.duplication_b.blame)
42
+ {
43
+ let key_a = line_a.to_string();
44
+ let key_b = line_b.to_string();
45
+ let author_a = blame_a
46
+ .get(&key_a)
47
+ .map(|line| line.author.as_str())
48
+ .unwrap_or("");
49
+ let author_b = blame_b
50
+ .get(&key_b)
51
+ .map(|line| line.author.as_str())
52
+ .unwrap_or("");
53
+ let date_cmp = blame_a
54
+ .get(&key_a)
55
+ .zip(blame_b.get(&key_b))
56
+ .map(|(left, right)| compare_dates(&left.date, &right.date))
57
+ .unwrap_or("");
58
+ output.push_str(&format!(
59
+ " {line_a:>width_a$} {GREY}│{RESET_COLOR} {author_a} {GREY}│{RESET_COLOR} {date_cmp} {GREY}│{RESET_COLOR} {line_b:<width_b$} {GREY}│{RESET_COLOR} {author_b} {GREY}│{RESET_COLOR} {GREY}{line}{RESET_COLOR} ",
60
+ ));
61
+ } else {
62
+ output.push_str(&format!(
63
+ " {line_a:>width_a$} {GREY}│{RESET_COLOR} {line_b:<width_b$} {GREY}│{RESET_COLOR} {GREY}{line}{RESET_COLOR} ",
64
+ ));
65
+ }
66
+ }
67
+
68
+ output.push('\n');
69
+ output
70
+ }
71
+
72
+ fn compare_dates(first: &str, second: &str) -> &'static str {
73
+ match first.cmp(second) {
74
+ std::cmp::Ordering::Less => "=>",
75
+ std::cmp::Ordering::Greater => "<=",
76
+ std::cmp::Ordering::Equal => "==",
77
+ }
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::*;
83
+ use crate::report::console_common::clone_header;
84
+ use crate::report::test_support::{make_test_result_with_clone, single_line_blame};
85
+
86
+ #[test]
87
+ fn console_full_header_matches_upstream_shape() {
88
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
89
+ let header = clone_header(&result.clones[0], &Options::default());
90
+
91
+ assert!(header.starts_with("Clone found (javascript):\n - "));
92
+ assert!(header.contains("src/a.js"));
93
+ assert!(header.contains("[2:3 - 5:1] (3 lines, 18 tokens)"));
94
+ assert!(header.contains("src/b.js"));
95
+ assert!(header.contains("[8:1 - 11:1]"));
96
+ }
97
+
98
+ #[test]
99
+ fn console_full_fragment_table_uses_source_fragment_lines() {
100
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
101
+ let table = fragment_table(&result, &result.clones[0]);
102
+
103
+ assert!(table.contains(" 2 "));
104
+ assert!(table.contains(" 8 "));
105
+ assert!(table.contains("alpha <beta> ]]>"));
106
+ }
107
+
108
+ #[test]
109
+ fn console_full_fragment_table_uses_blame_columns_when_available() {
110
+ let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
111
+ result.clones[0].duplication_a.blame = Some(single_line_blame(
112
+ "2",
113
+ "a",
114
+ "Alice",
115
+ "2024-01-01 00:00:00 +0000",
116
+ ));
117
+ result.clones[0].duplication_b.blame = Some(single_line_blame(
118
+ "8",
119
+ "b",
120
+ "Bob",
121
+ "2024-01-02 00:00:00 +0000",
122
+ ));
123
+
124
+ let table = fragment_table(&result, &result.clones[0]);
125
+
126
+ assert!(table.contains("Alice"));
127
+ assert!(table.contains("Bob"));
128
+ assert!(table.contains("=>"));
129
+ }
130
+
131
+ #[test]
132
+ fn console_full_report_prints_final_clone_count() {
133
+ let result = make_test_result_with_clone("src/a.js", "src/b.js");
134
+ let report = console_full_report(&result, &Options::default());
135
+
136
+ assert!(report.contains("Clone found (javascript):"));
137
+ assert!(report.contains("Found 1 clones."));
138
+ }
139
+ }
@@ -0,0 +1,65 @@
1
+ use anyhow::Result;
2
+
3
+ use super::file_output::write_file_report;
4
+ use super::summary::summary_rows;
5
+ use crate::cli::Options;
6
+ use crate::detector::Statistics;
7
+
8
+ pub(super) fn write(result: &crate::detector::DetectionResult, options: &Options) -> Result<()> {
9
+ let csv = CsvReport::from_statistics(&result.statistics).to_string();
10
+ write_file_report(options, "jscpd-report.csv", "CSV report", csv)
11
+ }
12
+
13
+ struct CsvReport {
14
+ rows: Vec<[String; 7]>,
15
+ }
16
+
17
+ impl CsvReport {
18
+ fn from_statistics(statistics: &Statistics) -> Self {
19
+ Self {
20
+ rows: summary_rows(statistics),
21
+ }
22
+ }
23
+ }
24
+
25
+ impl std::fmt::Display for CsvReport {
26
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27
+ for (idx, row) in self.rows.iter().enumerate() {
28
+ if idx > 0 {
29
+ writeln!(f)?;
30
+ }
31
+ write!(f, "{}", row.join(","))?;
32
+ }
33
+ Ok(())
34
+ }
35
+ }
36
+
37
+ #[cfg(test)]
38
+ mod tests {
39
+ use super::*;
40
+ use crate::report::test_support::{make_test_statistics, write_test_report};
41
+
42
+ #[test]
43
+ fn csv_report_matches_upstream_summary_shape() {
44
+ let stats = make_test_statistics();
45
+ let report = CsvReport::from_statistics(&stats);
46
+ let csv = report.to_string();
47
+
48
+ assert_eq!(
49
+ csv,
50
+ [
51
+ "Format,Files analyzed,Total lines,Total tokens,Clones found,Duplicated lines,Duplicated tokens",
52
+ "javascript,2,20,100,1,5 (25%),30 (30%)",
53
+ "Total:,2,20,100,1,5 (25%),30 (30%)",
54
+ ]
55
+ .join("\n")
56
+ );
57
+ }
58
+
59
+ #[test]
60
+ fn write_reports_writes_csv_report() {
61
+ let csv = write_test_report("csv", "csv-report", &["jscpd-report.csv"]);
62
+
63
+ assert!(csv.starts_with("Format,Files analyzed,Total lines"));
64
+ }
65
+ }
@@ -0,0 +1,8 @@
1
+ pub(super) fn escape_xml(value: &str) -> String {
2
+ value
3
+ .replace('&', "&amp;")
4
+ .replace('<', "&lt;")
5
+ .replace('>', "&gt;")
6
+ .replace('\'', "&apos;")
7
+ .replace('"', "&quot;")
8
+ }