1use std::collections::HashSet;
2use std::ffi::OsString;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result, bail};
6use clap::Parser;
7use regex::Regex;
8use time::OffsetDateTime;
9use time::format_description::well_known::Rfc3339;
10
11use crate::files::collect_cwd_gitignore_patterns;
12
13mod config;
14mod parsing;
15#[cfg(test)]
16mod tests;
17
18#[cfg(test)]
19use config::{FileConfig, resolve_config_ignore};
20use config::{apply_config, read_config, read_package_json_config};
21#[cfg(test)]
22use parsing::parse_format_mappings;
23use parsing::{
24 compile_patterns, parse_format_mappings_like_upstream, parse_js_number, parse_js_usize,
25 parse_size, split_csv,
26};
27
28const BARE_EXIT_CODE_VALUE: &str = "__jscpd_rs_bare_exit_code_true__";
29const BARE_CONFIG_VALUE: &str = "__jscpd_rs_bare_config_true__";
30const BARE_STRING_VALUE: &str = "__jscpd_rs_bare_string_true__";
31
32#[doc(hidden)]
33#[derive(Debug, Parser)]
34#[command(
35 name = "jscpd",
36 version,
37 about = "detector of copy/paste in files",
38 override_usage = "jscpd [options] <path ...>",
39 disable_version_flag = true,
40 args_override_self = true
41)]
42pub struct Cli {
43 #[arg(short = 'V', long = "version", help = "output the version number")]
44 pub version: bool,
45
46 #[arg(value_name = "path", hide = true)]
47 pub paths: Vec<PathBuf>,
48
49 #[arg(
50 short = 'l',
51 long = "min-lines",
52 value_name = "number",
53 num_args = 0..=1,
54 default_missing_value = "0",
55 value_parser = parse_js_usize,
56 help = "min size of duplication in code lines (Default is 5)"
57 )]
58 pub min_lines: Option<usize>,
59
60 #[arg(
61 short = 'k',
62 long = "min-tokens",
63 value_name = "number",
64 num_args = 0..=1,
65 default_missing_value = "50",
66 value_parser = parse_js_usize,
67 help = "min size of duplication in code tokens (Default is 50)"
68 )]
69 pub min_tokens: Option<usize>,
70
71 #[arg(
72 short = 'x',
73 long = "max-lines",
74 value_name = "number",
75 num_args = 0..=1,
76 default_missing_value = "18446744073709551615",
77 value_parser = parse_js_usize,
78 help = "max size of source in lines (Default is 1000)"
79 )]
80 pub max_lines: Option<usize>,
81
82 #[arg(
83 short = 'z',
84 long = "max-size",
85 value_name = "string",
86 num_args = 0..=1,
87 default_missing_value = "true",
88 help = "max size of source in bytes, examples: 1kb, 1mb, 120kb (Default is 100kb)"
89 )]
90 pub max_size: Option<String>,
91
92 #[arg(
93 short = 't',
94 long = "threshold",
95 value_name = "number",
96 num_args = 0..=1,
97 default_missing_value = "1",
98 value_parser = parse_js_number,
99 help = "threshold for duplication, in case duplications >= threshold jscpd will exit with error"
100 )]
101 pub threshold: Option<f64>,
102
103 #[arg(
104 short = 'c',
105 long = "config",
106 value_name = "string",
107 num_args = 0..=1,
108 default_missing_value = BARE_CONFIG_VALUE,
109 help = "path to config file (Default is .jscpd.json in <path>)"
110 )]
111 pub config: Option<PathBuf>,
112
113 #[arg(
114 short = 'i',
115 long = "ignore",
116 value_name = "string",
117 num_args = 0..=1,
118 default_missing_value = BARE_STRING_VALUE,
119 help = "glob pattern for files what should be excluded from duplication detection"
120 )]
121 pub ignore: Option<String>,
122
123 #[arg(
124 short = 'r',
125 long = "reporters",
126 value_name = "string",
127 num_args = 0..=1,
128 default_missing_value = BARE_STRING_VALUE,
129 help = "reporters or list of reporters separated with comma to use (Default is time,console)"
130 )]
131 pub reporters: Option<String>,
132
133 #[arg(
134 short = 'o',
135 long = "output",
136 value_name = "string",
137 num_args = 0..=1,
138 default_missing_value = BARE_STRING_VALUE,
139 help = "reporters to use (Default is ./report/)"
140 )]
141 pub output: Option<String>,
142
143 #[arg(
144 short = 'm',
145 long = "mode",
146 value_name = "string",
147 num_args = 0..=1,
148 default_missing_value = BARE_STRING_VALUE,
149 help = "mode of quality of search, can be \"strict\", \"mild\" and \"weak\""
150 )]
151 pub mode: Option<String>,
152
153 #[arg(
154 short = 'f',
155 long = "format",
156 value_name = "string",
157 num_args = 0..=1,
158 default_missing_value = BARE_STRING_VALUE,
159 help = "format or formats separated by comma (Example php,javascript,python)"
160 )]
161 pub format: Option<String>,
162
163 #[arg(
164 short = 'p',
165 long = "pattern",
166 value_name = "string",
167 num_args = 0..=1,
168 default_missing_value = "true",
169 help = "glob pattern to file search (Example **/*.txt)"
170 )]
171 pub pattern: Option<String>,
172
173 #[arg(
174 short = 'b',
175 long = "blame",
176 help = "blame authors of duplications (get information about authors from git)"
177 )]
178 pub blame: bool,
179
180 #[arg(
181 short = 's',
182 long = "silent",
183 help = "do not write detection progress and result to a console"
184 )]
185 pub silent: bool,
186
187 #[arg(
188 long = "store",
189 value_name = "string",
190 num_args = 0..=1,
191 default_missing_value = "true",
192 help = "use for define custom store (e.g. --store leveldb used for big codebase)"
193 )]
194 pub store: Option<String>,
195
196 #[arg(
197 long = "store-path",
198 value_name = "string",
199 num_args = 0..=1,
200 default_missing_value = "true",
201 help = "directory to use for store cache (e.g. --store-path /tmp/jscpd-cache, useful when running multiple instances in parallel)"
202 )]
203 pub store_path: Option<PathBuf>,
204
205 #[arg(short = 'a', long = "absolute", help = "use absolute path in reports")]
206 pub absolute: bool,
207
208 #[arg(
209 short = 'n',
210 long = "noSymlinks",
211 help = "dont use symlinks for detection in files"
212 )]
213 pub no_symlinks: bool,
214
215 #[arg(
216 long = "ignoreCase",
217 help = "ignore case of symbols in code (experimental)"
218 )]
219 pub ignore_case: bool,
220
221 #[arg(
222 short = 'g',
223 long = "gitignore",
224 help = "respect .gitignore files (default: enabled, use --no-gitignore to disable)"
225 )]
226 pub gitignore: bool,
227
228 #[arg(long = "no-gitignore", help = "do not respect .gitignore files")]
229 pub no_gitignore: bool,
230
231 #[arg(
232 short = 'd',
233 long = "debug",
234 help = "show debug information, not run detection process(options list and selected files)"
235 )]
236 pub debug: bool,
237
238 #[arg(
239 short = 'v',
240 long = "verbose",
241 help = "show full information during detection process"
242 )]
243 pub verbose: bool,
244
245 #[arg(long = "list", help = "show list of total supported formats")]
246 pub list: bool,
247
248 #[arg(
249 long = "skipLocal",
250 help = "skip duplicates in local folders, just detect cross folders duplications"
251 )]
252 pub skip_local: bool,
253
254 #[arg(
255 long = "exitCode",
256 value_name = "number",
257 num_args = 0..=1,
258 default_missing_value = "__jscpd_rs_bare_exit_code_true__",
259 help = "exit code to use when code duplications are detected"
260 )]
261 pub exit_code: Option<String>,
262
263 #[arg(
264 long = "noTips",
265 help = "do not print tips and promotional messages after detection"
266 )]
267 pub no_tips: bool,
268
269 #[arg(
270 long = "skipComments",
271 help = "ignore comments during detection (alias for --mode weak)"
272 )]
273 pub skip_comments: bool,
274
275 #[arg(
276 long = "ignore-pattern",
277 value_name = "string",
278 num_args = 0..=1,
279 default_missing_value = BARE_STRING_VALUE,
280 help = "Ignore code blocks matching the regexp patterns"
281 )]
282 pub ignore_pattern: Option<String>,
283
284 #[arg(
285 long = "formats-exts",
286 value_name = "string",
287 num_args = 0..=1,
288 default_missing_value = BARE_STRING_VALUE,
289 help = "list of formats with file extensions (javascript:es,es6;dart:dt)"
290 )]
291 pub formats_exts: Option<String>,
292
293 #[arg(
294 long = "formats-names",
295 value_name = "string",
296 num_args = 0..=1,
297 default_missing_value = BARE_STRING_VALUE,
298 help = "list of formats with specific filenames (makefile:Makefile,GNUmakefile;docker:Dockerfile)"
299 )]
300 pub formats_names: Option<String>,
301}
302
303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305pub enum Mode {
306 Strict,
308 Mild,
310 Weak,
312}
313
314#[derive(Clone, Debug, PartialEq)]
319pub enum ExitCode {
320 Number(f64),
322 String(String),
324 Boolean(bool),
326}
327
328impl ExitCode {
329 fn from_cli(value: String) -> Self {
330 if value == BARE_EXIT_CODE_VALUE {
331 Self::Boolean(true)
332 } else {
333 Self::String(value)
334 }
335 }
336}
337
338#[derive(Debug, Clone)]
345pub struct Options {
346 pub execution_id: Option<String>,
348 pub config: Option<PathBuf>,
350 pub paths: Vec<PathBuf>,
352 pub pattern: String,
354 pub ignore: Vec<String>,
356 pub reporters: Vec<String>,
358 pub listeners: Vec<String>,
360 pub reporters_options: serde_json::Map<String, serde_json::Value>,
362 pub output: PathBuf,
364 pub output_is_bare: bool,
366 pub formats: Option<HashSet<String>>,
368 pub format_order: Option<Vec<String>>,
370 pub formats_exts: FormatMappings,
372 pub formats_names: FormatMappings,
374 pub ignore_pattern: Vec<Regex>,
376 pub min_lines: usize,
378 pub min_tokens: usize,
380 pub max_lines: usize,
382 pub max_size_bytes: u64,
384 pub threshold: Option<f64>,
386 pub mode: Mode,
388 pub store: Option<String>,
390 pub store_path: Option<PathBuf>,
392 pub blame: bool,
394 pub cache: bool,
396 pub silent: bool,
398 pub absolute: bool,
400 pub no_symlinks: bool,
402 pub ignore_case: bool,
404 pub gitignore: bool,
406 pub debug: bool,
408 pub verbose: bool,
410 pub skip_local: bool,
412 pub exit_code: ExitCode,
414 pub no_tips: bool,
416 pub tokens_to_skip: Vec<String>,
418}
419
420#[derive(Clone, Debug, Default, PartialEq, Eq)]
425pub struct FormatMappings(Vec<(String, Vec<String>)>);
426
427impl FormatMappings {
428 pub fn from_pairs<I, S, V, T>(pairs: I) -> Self
430 where
431 I: IntoIterator<Item = (S, V)>,
432 S: Into<String>,
433 V: IntoIterator<Item = T>,
434 T: Into<String>,
435 {
436 Self(
437 pairs
438 .into_iter()
439 .map(|(format, values)| {
440 (format.into(), values.into_iter().map(Into::into).collect())
441 })
442 .collect(),
443 )
444 }
445
446 pub fn is_empty(&self) -> bool {
448 self.0.is_empty()
449 }
450
451 pub fn iter(&self) -> impl Iterator<Item = (&String, &Vec<String>)> {
453 self.0.iter().map(|(format, values)| (format, values))
454 }
455
456 pub fn find_format_for_value(&self, value: &str) -> Option<&str> {
458 self.0.iter().find_map(|(format, values)| {
459 values
460 .iter()
461 .any(|item| item == value)
462 .then_some(format.as_str())
463 })
464 }
465}
466
467impl Default for Options {
468 fn default() -> Self {
469 Self {
470 execution_id: Some(default_execution_id()),
471 config: None,
472 paths: vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))],
473 pattern: "**/*".to_string(),
474 ignore: Vec::new(),
475 reporters: vec!["console".to_string()],
476 listeners: Vec::new(),
477 reporters_options: serde_json::Map::new(),
478 output: PathBuf::from("./report"),
479 output_is_bare: false,
480 formats: None,
481 format_order: None,
482 formats_exts: FormatMappings::default(),
483 formats_names: FormatMappings::default(),
484 ignore_pattern: Vec::new(),
485 min_lines: 5,
486 min_tokens: 50,
487 max_lines: 1000,
488 max_size_bytes: 100 * 1024,
489 threshold: None,
490 mode: Mode::Mild,
491 store: None,
492 store_path: None,
493 blame: false,
494 cache: true,
495 silent: false,
496 absolute: false,
497 no_symlinks: false,
498 ignore_case: false,
499 gitignore: true,
500 debug: false,
501 verbose: false,
502 skip_local: false,
503 exit_code: ExitCode::Number(0.0),
504 no_tips: std::env::var_os("CI").is_some(),
505 tokens_to_skip: Vec::new(),
506 }
507 }
508}
509
510fn default_execution_id() -> String {
511 OffsetDateTime::now_utc()
512 .format(&Rfc3339)
513 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
514}
515
516impl Options {
517 pub fn from_args<I, T>(args: I) -> Result<Self>
521 where
522 I: IntoIterator<Item = T>,
523 T: Into<OsString> + Clone,
524 {
525 let cli = Cli::try_parse_from(args)?;
526 Self::from_cli(cli)
527 }
528
529 pub fn from_cli(cli: Cli) -> Result<Self> {
534 let mut options = Self::default();
535
536 if matches!(cli.config.as_deref(), Some(path) if path == std::path::Path::new(BARE_CONFIG_VALUE))
537 {
538 bail!(
539 "TypeError [ERR_INVALID_ARG_TYPE]: The \"paths[0]\" argument must be of type string. Received type boolean (true)"
540 );
541 }
542
543 if let Some((config, config_dir, config_path)) = read_package_json_config()? {
544 options.config = Some(config_path);
545 apply_config(&mut options, config, &config_dir)?;
546 }
547 if let Some((config, config_dir, config_path)) = read_config(cli.config.as_deref())? {
548 options.config = Some(config_path);
549 apply_config(&mut options, config, &config_dir)?;
550 }
551
552 if !cli.paths.is_empty() {
553 options.paths = cli.paths;
554 }
555 if let Some(pattern) = cli.pattern {
556 options.pattern = pattern;
557 }
558 if let Some(ignore) = cli.ignore {
559 if is_bare_string(&ignore) {
560 bail!("TypeError: cli.ignore.split is not a function");
561 }
562 options.ignore = split_csv(&ignore);
563 }
564 if let Some(reporters) = cli.reporters {
565 if is_bare_string(&reporters) {
566 bail!("TypeError: cli.reporters.split is not a function");
567 }
568 options.reporters = split_csv(&reporters);
569 }
570 if let Some(output) = cli.output {
571 if is_bare_string(&output) {
572 options.output = PathBuf::from("true");
573 options.output_is_bare = true;
574 } else {
575 options.output = PathBuf::from(output);
576 options.output_is_bare = false;
577 }
578 }
579 if let Some(format) = cli.format {
580 if is_bare_string(&format) {
581 bail!("TypeError: cli.format.split is not a function");
582 }
583 let formats = split_csv(&format);
584 options.formats = Some(formats.iter().cloned().collect());
585 options.format_order = Some(formats);
586 }
587 if let Some(formats_exts) = cli.formats_exts {
588 if is_bare_string(&formats_exts) {
589 bail!("TypeError: extensions.split is not a function");
590 }
591 options.formats_exts = parse_format_mappings_like_upstream(&formats_exts)?;
592 }
593 if let Some(formats_names) = cli.formats_names {
594 if is_bare_string(&formats_names) {
595 bail!("TypeError: extensions.split is not a function");
596 }
597 options.formats_names = parse_format_mappings_like_upstream(&formats_names)?;
598 }
599 if let Some(ignore_pattern) = cli.ignore_pattern {
600 if is_bare_string(&ignore_pattern) {
601 bail!("TypeError: cli.ignorePattern.split is not a function");
602 }
603 options.ignore_pattern = compile_patterns(split_csv(&ignore_pattern))
604 .context("invalid --ignore-pattern value")?;
605 }
606 if let Some(min_lines) = cli.min_lines {
607 options.min_lines = min_lines;
608 }
609 if let Some(min_tokens) = cli.min_tokens {
610 options.min_tokens = min_tokens;
611 }
612 if let Some(max_lines) = cli.max_lines {
613 options.max_lines = max_lines;
614 }
615 if let Some(max_size) = cli.max_size {
616 options.max_size_bytes = parse_size(&max_size)
617 .with_context(|| format!("invalid --max-size value `{max_size}`"))?;
618 }
619 if let Some(threshold) = cli.threshold {
620 options.threshold = Some(threshold);
621 }
622 if let Some(mode) = cli.mode.as_deref() {
623 if is_bare_string(mode) {
624 bail!("TypeError: mode is not a function");
625 }
626 options.mode = parse_mode(mode)?;
627 }
628 if cli.skip_comments && cli.mode.is_none() {
629 options.mode = Mode::Weak;
630 }
631 if let Some(store) = cli.store {
632 options.store = Some(store);
633 }
634 if let Some(store_path) = cli.store_path {
635 options.store_path = Some(store_path);
636 }
637 if cli.blame {
638 options.blame = true;
639 }
640 if cli.silent {
641 options.silent = true;
642 }
643 if cli.absolute {
644 options.absolute = true;
645 }
646 if cli.no_symlinks {
647 options.no_symlinks = true;
648 }
649 if cli.ignore_case {
650 options.ignore_case = true;
651 }
652 if cli.no_gitignore {
653 options.gitignore = false;
654 } else if cli.gitignore {
655 options.gitignore = true;
656 }
657 if cli.debug {
658 options.debug = true;
659 }
660 if cli.verbose {
661 options.verbose = true;
662 }
663 if cli.skip_local {
664 options.skip_local = true;
665 }
666 if let Some(exit_code) = cli.exit_code {
667 options.exit_code = ExitCode::from_cli(exit_code);
668 }
669 if cli.no_tips {
670 options.no_tips = true;
671 }
672
673 apply_cwd_gitignore_patterns(&mut options)?;
674 normalize_reporters(&mut options);
675
676 Ok(options)
677 }
678}
679
680fn is_bare_string(value: &str) -> bool {
681 value == BARE_STRING_VALUE
682}
683
684pub fn resolve_node_exit_code(exit_code: &ExitCode) -> std::result::Result<i32, String> {
685 parsing::node_exit_code(exit_code).map_err(|error| error.message())
686}
687
688pub fn store_warning(options: &Options) -> Option<String> {
689 options
690 .store
691 .as_ref()
692 .map(|store| format!("store name {store} not installed."))
693}
694
695pub(super) fn parse_mode(value: &str) -> Result<Mode> {
696 match value {
697 "strict" => Ok(Mode::Strict),
698 "mild" => Ok(Mode::Mild),
699 "weak" => Ok(Mode::Weak),
700 _ => bail!("Mode {value} does not supported yet."),
701 }
702}
703
704fn normalize_reporters(options: &mut Options) {
705 if options.silent {
706 options
707 .reporters
708 .retain(|reporter| !reporter.contains("console"));
709 options.reporters.push("silent".to_string());
710 }
711 if options.threshold.is_some() {
712 options.reporters.push("threshold".to_string());
713 }
714}
715
716fn apply_cwd_gitignore_patterns(options: &mut Options) -> Result<()> {
717 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
718 apply_gitignore_patterns_from(options, &cwd);
719 Ok(())
720}
721
722fn apply_gitignore_patterns_from(options: &mut Options, cwd: &std::path::Path) {
723 if options.gitignore {
724 options.ignore.extend(collect_cwd_gitignore_patterns(cwd));
725 }
726}