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.
- package/CHANGELOG.md +69 -0
- package/Cargo.lock +1323 -0
- package/Cargo.toml +54 -0
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/docs/api-parity.md +49 -0
- package/docs/cloning-plan.md +281 -0
- package/docs/compat-baseline.md +535 -0
- package/docs/format-porting.md +86 -0
- package/docs/junior-task-template.md +62 -0
- package/docs/junior-workflow.md +87 -0
- package/docs/migrating-from-jscpd.md +193 -0
- package/docs/npm-release.md +116 -0
- package/docs/public-benchmark-suite.md +81 -0
- package/docs/release-checklist.md +200 -0
- package/docs/release-decisions.md +103 -0
- package/docs/release-readiness.md +51 -0
- package/docs/upstream-bugs.md +501 -0
- package/docs/upstream-issue-drafts.md +393 -0
- package/docs/user-guide.md +309 -0
- package/examples/dump_oxc_tokens.rs +112 -0
- package/examples/library_api.rs +42 -0
- package/npm/bin/jscpd-rs.js +6 -0
- package/npm/bin/jscpd-server.js +6 -0
- package/npm/lib/run-binary.js +68 -0
- package/npm/scripts/postinstall.js +50 -0
- package/package.json +53 -0
- package/skills/dry-refactoring/SKILL.md +63 -0
- package/skills/jscpd/SKILL.md +85 -0
- package/src/app.rs +512 -0
- package/src/bin/jscpd-server.rs +429 -0
- package/src/blame.rs +130 -0
- package/src/cli/config.rs +543 -0
- package/src/cli/parsing.rs +301 -0
- package/src/cli/tests.rs +543 -0
- package/src/cli.rs +671 -0
- package/src/detector/matching/secondary.rs +387 -0
- package/src/detector/matching.rs +274 -0
- package/src/detector/model.rs +190 -0
- package/src/detector/prepare.rs +71 -0
- package/src/detector/skip_local.rs +40 -0
- package/src/detector/statistics.rs +138 -0
- package/src/detector/store.rs +96 -0
- package/src/detector/tests.rs +238 -0
- package/src/detector.rs +265 -0
- package/src/files/discovery.rs +508 -0
- package/src/files/gitignore.rs +203 -0
- package/src/files/paths.rs +68 -0
- package/src/files/shebang.rs +106 -0
- package/src/files/tests.rs +523 -0
- package/src/files.rs +25 -0
- package/src/formats.rs +570 -0
- package/src/lib.rs +433 -0
- package/src/main.rs +26 -0
- package/src/report/ai.rs +125 -0
- package/src/report/badge.rs +238 -0
- package/src/report/console.rs +180 -0
- package/src/report/console_common.rs +37 -0
- package/src/report/console_full.rs +139 -0
- package/src/report/csv.rs +65 -0
- package/src/report/escape.rs +8 -0
- package/src/report/file_output.rs +28 -0
- package/src/report/html/assets.rs +47 -0
- package/src/report/html.rs +336 -0
- package/src/report/json.rs +119 -0
- package/src/report/markdown.rs +125 -0
- package/src/report/sarif.rs +302 -0
- package/src/report/silent.rs +22 -0
- package/src/report/source.rs +38 -0
- package/src/report/summary.rs +50 -0
- package/src/report/test_support.rs +133 -0
- package/src/report/threshold.rs +76 -0
- package/src/report/xcode.rs +90 -0
- package/src/report/xml.rs +119 -0
- package/src/report.rs +250 -0
- package/src/server/mcp.rs +942 -0
- package/src/server.rs +1081 -0
- package/src/tokenizer/apex.rs +97 -0
- package/src/tokenizer/blocks.rs +532 -0
- package/src/tokenizer/embedded.rs +106 -0
- package/src/tokenizer/generic.rs +511 -0
- package/src/tokenizer/hash.rs +27 -0
- package/src/tokenizer/ignore.rs +33 -0
- package/src/tokenizer/line_index.rs +33 -0
- package/src/tokenizer/markdown.rs +289 -0
- package/src/tokenizer/markup_attrs.rs +289 -0
- package/src/tokenizer/oxc/fallback.rs +275 -0
- package/src/tokenizer/oxc/jsx.rs +168 -0
- package/src/tokenizer/oxc/kind.rs +177 -0
- package/src/tokenizer/oxc/lexical.rs +67 -0
- package/src/tokenizer/oxc.rs +659 -0
- package/src/tokenizer/scan.rs +88 -0
- package/src/tokenizer/tap.rs +150 -0
- package/src/tokenizer/tests.rs +915 -0
- package/src/tokenizer.rs +328 -0
- 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(÷r('┌', '┬', '┐', &widths));
|
|
43
|
+
table.push_str(&row(
|
|
44
|
+
&HEADERS.map(str::to_string),
|
|
45
|
+
&widths,
|
|
46
|
+
RowStyle::Header,
|
|
47
|
+
));
|
|
48
|
+
table.push_str(÷r('├', '┼', '┤', &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(÷r('├', '┼', '┤', &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(÷r('└', '┴', '┘', &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
|
+
}
|