duplicatecode 0.1.0__tar.gz → 0.2.0__tar.gz

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 (20) hide show
  1. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/Cargo.lock +13 -2
  2. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/Cargo.toml +1 -1
  3. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/PKG-INFO +3 -3
  4. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/README.md +1 -1
  5. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-cli/src/main.rs +1 -1
  6. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/Cargo.toml +2 -1
  7. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/index.rs +3 -0
  8. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/lang.rs +6 -1
  9. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/units.rs +135 -16
  10. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/pyproject.toml +1 -1
  11. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-cli/Cargo.toml +0 -0
  12. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-cli/src/bench.rs +0 -0
  13. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-cli/src/review.rs +0 -0
  14. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/diff.rs +0 -0
  15. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/embed.rs +0 -0
  16. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/fingerprint.rs +0 -0
  17. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/lib.rs +0 -0
  18. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/mutate.rs +0 -0
  19. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/naming.rs +0 -0
  20. {duplicatecode-0.1.0 → duplicatecode-0.2.0}/crates/duplicatecode-engine/src/similarity.rs +0 -0
@@ -198,7 +198,7 @@ dependencies = [
198
198
 
199
199
  [[package]]
200
200
  name = "duplicatecode"
201
- version = "0.1.0"
201
+ version = "0.2.0"
202
202
  dependencies = [
203
203
  "anyhow",
204
204
  "clap",
@@ -212,12 +212,13 @@ dependencies = [
212
212
 
213
213
  [[package]]
214
214
  name = "duplicatecode-engine"
215
- version = "0.1.0"
215
+ version = "0.2.0"
216
216
  dependencies = [
217
217
  "ignore",
218
218
  "serde",
219
219
  "serde_json",
220
220
  "tree-sitter",
221
+ "tree-sitter-c-sharp",
221
222
  "tree-sitter-python",
222
223
  "tree-sitter-typescript",
223
224
  "ureq",
@@ -763,6 +764,16 @@ dependencies = [
763
764
  "tree-sitter-language",
764
765
  ]
765
766
 
767
+ [[package]]
768
+ name = "tree-sitter-c-sharp"
769
+ version = "0.23.5"
770
+ source = "registry+https://github.com/rust-lang/crates.io-index"
771
+ checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
772
+ dependencies = [
773
+ "cc",
774
+ "tree-sitter-language",
775
+ ]
776
+
766
777
  [[package]]
767
778
  name = "tree-sitter-language"
768
779
  version = "0.1.8"
@@ -3,6 +3,6 @@ resolver = "2"
3
3
  members = ["crates/duplicatecode-engine", "crates/duplicatecode-cli"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.0"
6
+ version = "0.2.0"
7
7
  edition = "2021"
8
8
  license = "MIT"
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duplicatecode
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Environment :: Console
6
- Summary: Static (LLM-free) detection of duplicate/similar code in Python and TypeScript
6
+ Summary: Static (LLM-free) detection of duplicate/similar code in Python, TypeScript and C#
7
7
  License: MIT
8
8
  Requires-Python: >=3.9
9
9
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
10
10
 
11
11
  # duplicatecode
12
12
 
13
- Static (LLM-free) detection of duplicate / similar code in Python and TypeScript (TSX),
13
+ Static (LLM-free) detection of duplicate / similar code in Python, TypeScript (TSX) and C#,
14
14
  aimed at catching an LLM re-implementing something that already exists. Input can be a git diff
15
15
  checked against existing source.
16
16
 
@@ -1,6 +1,6 @@
1
1
  # duplicatecode
2
2
 
3
- Static (LLM-free) detection of duplicate / similar code in Python and TypeScript (TSX),
3
+ Static (LLM-free) detection of duplicate / similar code in Python, TypeScript (TSX) and C#,
4
4
  aimed at catching an LLM re-implementing something that already exists. Input can be a git diff
5
5
  checked against existing source.
6
6
 
@@ -80,7 +80,7 @@ impl Profile {
80
80
  #[derive(Parser)]
81
81
  #[command(
82
82
  version,
83
- about = "Detect duplicate/similar code in Python and TypeScript"
83
+ about = "Detect duplicate/similar code in Python, TypeScript and C#"
84
84
  )]
85
85
  struct Cli {
86
86
  #[command(subcommand)]
@@ -3,13 +3,14 @@ name = "duplicatecode-engine"
3
3
  version.workspace = true
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
- description = "Static duplicate/similar code detection engine for Python and TypeScript"
6
+ description = "Static duplicate/similar code detection engine for Python, TypeScript and C#"
7
7
 
8
8
  [dependencies]
9
9
  ignore = "0.4.33"
10
10
  serde = { version = "1.0.229", features = ["derive"] }
11
11
  serde_json = "1.0.151"
12
12
  tree-sitter = "0.27.0"
13
+ tree-sitter-c-sharp = "0.23.5"
13
14
  tree-sitter-python = "0.25.0"
14
15
  tree-sitter-typescript = "0.23.2"
15
16
  ureq = { version = "2", features = ["json"] }
@@ -406,6 +406,9 @@ pub fn units_from_file(root: &std::path::Path, path: &std::path::Path) -> Vec<Un
406
406
 
407
407
  const SKIP_DIRS: &[&str] = &[
408
408
  "node_modules",
409
+ "bin",
410
+ "obj",
411
+ ".vs",
409
412
  "target",
410
413
  ".venv",
411
414
  "venv",
@@ -6,6 +6,8 @@ pub enum Lang {
6
6
  Python,
7
7
  TypeScript,
8
8
  Tsx,
9
+ #[serde(rename = "csharp")]
10
+ CSharp,
9
11
  }
10
12
 
11
13
  impl Lang {
@@ -14,6 +16,7 @@ impl Lang {
14
16
  "py" => Some(Lang::Python),
15
17
  "ts" | "mts" | "cts" => Some(Lang::TypeScript),
16
18
  "tsx" => Some(Lang::Tsx),
19
+ "cs" => Some(Lang::CSharp),
17
20
  _ => None,
18
21
  }
19
22
  }
@@ -23,14 +26,16 @@ impl Lang {
23
26
  Lang::Python => tree_sitter_python::LANGUAGE.into(),
24
27
  Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
25
28
  Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
29
+ Lang::CSharp => tree_sitter_c_sharp::LANGUAGE.into(),
26
30
  }
27
31
  }
28
32
 
29
- /// Python vs. the TypeScript family; units are only compared within a family.
33
+ /// Python, C# or the TypeScript family; units are only compared within a family.
30
34
  pub fn family(self) -> &'static str {
31
35
  match self {
32
36
  Lang::Python => "python",
33
37
  Lang::TypeScript | Lang::Tsx => "typescript",
38
+ Lang::CSharp => "csharp",
34
39
  }
35
40
  }
36
41
  }
@@ -124,6 +124,12 @@ impl Ctx<'_> {
124
124
  }
125
125
 
126
126
  fn walk(&mut self, node: Node) {
127
+ // bodyless signatures (interfaces) and enums are not code units
128
+ if self.lang == Lang::CSharp
129
+ && matches!(node.kind(), "interface_declaration" | "enum_declaration")
130
+ {
131
+ return;
132
+ }
127
133
  if let Some((kind, name, unit_node)) = self.classify(node) {
128
134
  self.emit(kind, name, unit_node);
129
135
  // Only classes are searched for member units; nested functions belong to their parent.
@@ -170,6 +176,16 @@ impl Ctx<'_> {
170
176
  Some(("class", field_name(node)?, node))
171
177
  }
172
178
  (_, "method_definition") => Some(("function", field_name(node)?, node)),
179
+ (
180
+ Lang::CSharp,
181
+ "struct_declaration" | "record_declaration" | "record_struct_declaration",
182
+ ) => Some(("class", field_name(node)?, node)),
183
+ (Lang::CSharp, "method_declaration" | "local_function_statement") => {
184
+ Some(("function", field_name(node)?, node))
185
+ }
186
+ (Lang::CSharp, "constructor_declaration") => {
187
+ Some(("function", "constructor".into(), node))
188
+ }
173
189
  // const foo = () => ... / const foo = function () {...}; also class fields
174
190
  (Lang::TypeScript | Lang::Tsx, "variable_declarator" | "public_field_definition") => {
175
191
  let value = node.child_by_field_name("value")?;
@@ -281,6 +297,21 @@ impl Ctx<'_> {
281
297
  }
282
298
  _ => return false,
283
299
  },
300
+ "field_declaration" | "property_declaration" if self.lang == Lang::CSharp => {
301
+ if m.children(&mut m.walk())
302
+ .any(|c| c.kind() == "arrow_expression_clause")
303
+ || m.children(&mut m.walk()).any(|c| {
304
+ c.kind() == "accessor_list"
305
+ && c.children(&mut c.walk()).any(|a| {
306
+ a.kind() == "accessor_declaration"
307
+ && a.child_by_field_name("body").is_some()
308
+ })
309
+ })
310
+ {
311
+ return false;
312
+ }
313
+ fields += 1;
314
+ }
284
315
  "public_field_definition" | "property_signature" => {
285
316
  if m.child_by_field_name("value").is_some_and(|v| {
286
317
  matches!(
@@ -312,6 +343,35 @@ impl Ctx<'_> {
312
343
  fn is_debug(&self, node: Node) -> bool {
313
344
  match node.kind() {
314
345
  "debugger_statement" => true,
346
+ "expression_statement" if self.lang == Lang::CSharp => {
347
+ // Console.WriteLine(..), Debug.WriteLine(..), _logger.LogInformation(..)
348
+ let Some(f) = node
349
+ .named_child(0)
350
+ .filter(|e| e.kind() == "invocation_expression")
351
+ .and_then(|e| e.child_by_field_name("function"))
352
+ .filter(|f| f.kind() == "member_access_expression")
353
+ else {
354
+ return false;
355
+ };
356
+ let obj = f
357
+ .child_by_field_name("expression")
358
+ .map(|o| match o.kind() {
359
+ "member_access_expression" => o
360
+ .child_by_field_name("name")
361
+ .map(|x| self.text(x))
362
+ .unwrap_or_default(),
363
+ _ => self.text(o),
364
+ })
365
+ .unwrap_or_default();
366
+ let method = f
367
+ .child_by_field_name("name")
368
+ .map(|x| self.text(x))
369
+ .unwrap_or_default();
370
+ matches!(
371
+ obj.to_lowercase().as_str(),
372
+ "console" | "debug" | "trace" | "logger" | "_logger" | "log" | "_log"
373
+ ) && (method.starts_with("Write") || method.starts_with("Log"))
374
+ }
315
375
  "expression_statement" => {
316
376
  let Some(e) = node.named_child(0) else {
317
377
  return false;
@@ -354,7 +414,8 @@ impl Ctx<'_> {
354
414
 
355
415
  /// `x = expr` immediately followed by `return x` ==> the value expression of `return expr`.
356
416
  fn temp_return<'t>(&self, a: Node<'t>, b: Node<'t>) -> Option<Node<'t>> {
357
- if b.kind() != "return_statement" || b.named_child_count() != 1 {
417
+ if self.lang == Lang::CSharp || b.kind() != "return_statement" || b.named_child_count() != 1
418
+ {
358
419
  return None;
359
420
  }
360
421
  let (name, value) = if self.lang == Lang::Python {
@@ -401,16 +462,23 @@ impl Ctx<'_> {
401
462
  return;
402
463
  }
403
464
  match kind {
404
- "string" | "template_string" | "string_literal" | "concatenated_string" => {
405
- out.push("STR".into())
465
+ "string"
466
+ | "template_string"
467
+ | "string_literal"
468
+ | "concatenated_string"
469
+ | "verbatim_string_literal"
470
+ | "raw_string_literal"
471
+ | "interpolated_string_expression" => out.push("STR".into()),
472
+ "integer" | "float" | "number" | "integer_literal" | "real_literal" => {
473
+ out.push("NUM".into())
406
474
  }
407
- "integer" | "float" | "number" => out.push("NUM".into()),
408
475
  "identifier"
409
476
  | "property_identifier"
410
477
  | "shorthand_property_identifier"
411
478
  | "shorthand_property_identifier_pattern"
412
479
  | "type_identifier"
413
480
  | "private_property_identifier" => out.push("ID".into()),
481
+ "generic_name" if self.lang == Lang::CSharp => out.push("ID".into()),
414
482
  "list_comprehension"
415
483
  | "set_comprehension"
416
484
  | "dictionary_comprehension"
@@ -447,7 +515,7 @@ impl Ctx<'_> {
447
515
  _ => self.norm_children(node, out, skip_blocks),
448
516
  }
449
517
  }
450
- "block" | "statement_block" | "module" | "program" => {
518
+ "block" | "statement_block" | "module" | "program" | "compilation_unit" => {
451
519
  let n = node.child_count();
452
520
  let mut i = 0;
453
521
  while i < n {
@@ -543,7 +611,7 @@ impl Ctx<'_> {
543
611
  fn statements(&self, node: Node, out: &mut Vec<Stmt>) {
544
612
  let blockish = matches!(
545
613
  node.kind(),
546
- "block" | "statement_block" | "module" | "program"
614
+ "block" | "statement_block" | "module" | "program" | "compilation_unit"
547
615
  );
548
616
  let n = node.child_count();
549
617
  let mut i = 0;
@@ -698,7 +766,7 @@ impl Ctx<'_> {
698
766
  return;
699
767
  }
700
768
  match kind {
701
- "call" | "call_expression" => {
769
+ "call" | "call_expression" | "invocation_expression" => {
702
770
  if let Some(func) = node.child_by_field_name("function") {
703
771
  if let Some(n) = callee_name(func, self.src) {
704
772
  let n = n.to_lowercase();
@@ -707,17 +775,22 @@ impl Ctx<'_> {
707
775
  }
708
776
  }
709
777
  }
710
- "attribute" | "member_expression" => {
711
- let field = if kind == "attribute" {
712
- "attribute"
713
- } else {
714
- "property"
778
+ "attribute" | "member_expression" | "member_access_expression" => {
779
+ let field = match kind {
780
+ "attribute" => "attribute",
781
+ "member_access_expression" => "name",
782
+ _ => "property",
715
783
  };
716
784
  if let Some(a) = node.child_by_field_name(field) {
717
785
  f.api.insert(self.text(a).to_lowercase());
718
786
  }
719
787
  }
720
- "string" | "template_string" | "string_literal" | "concatenated_string" => {
788
+ "string"
789
+ | "template_string"
790
+ | "string_literal"
791
+ | "concatenated_string"
792
+ | "verbatim_string_literal"
793
+ | "raw_string_literal" => {
721
794
  let t = self.text(node);
722
795
  let t = t.trim_matches(|c| c == '"' || c == '\'' || c == '`');
723
796
  if !t.is_empty() && t.len() <= 24 && !t.contains('{') {
@@ -725,7 +798,7 @@ impl Ctx<'_> {
725
798
  }
726
799
  return;
727
800
  }
728
- "integer" | "float" | "number" => {
801
+ "integer" | "float" | "number" | "integer_literal" | "real_literal" => {
729
802
  let t = self.text(node);
730
803
  if !matches!(t.as_str(), "0" | "1" | "2" | "-1") {
731
804
  f.literals.insert(t);
@@ -764,6 +837,13 @@ const TYPE_KINDS: &[&str] = &[
764
837
  "type_arguments",
765
838
  "type_predicate_annotation",
766
839
  "asserts_annotation",
840
+ // C# type-level syntax
841
+ "type_argument_list",
842
+ "type_parameter_list",
843
+ "type_parameter_constraints_clause",
844
+ "nullable_type",
845
+ "modifier",
846
+ "predefined_type",
767
847
  // Python annotations
768
848
  "type",
769
849
  "type_parameter",
@@ -845,8 +925,13 @@ fn is_module_level(declarator: Node) -> bool {
845
925
  fn is_test_path(file: &str) -> bool {
846
926
  let f = file.replace('\\', "/");
847
927
  let base = f.rsplit('/').next().unwrap_or(&f);
848
- f.split('/')
849
- .any(|c| matches!(c, "tests" | "test" | "__tests__" | "e2e" | "spec"))
928
+ f.split('/').any(|c| {
929
+ matches!(c, "tests" | "test" | "__tests__" | "e2e" | "spec")
930
+ || c.ends_with(".Tests")
931
+ || c.ends_with(".Test")
932
+ }) || base.ends_with("Tests.cs")
933
+ || base.ends_with("Test.cs")
934
+ || base.ends_with("Spec.cs")
850
935
  || base.starts_with("test_")
851
936
  || base == "conftest.py"
852
937
  || base.ends_with("_test.py")
@@ -866,6 +951,7 @@ fn callee_name(f: Node, src: &[u8]) -> Option<String> {
866
951
  match f.kind() {
867
952
  "identifier" | "property_identifier" => text(f),
868
953
  "attribute" => f.child_by_field_name("attribute").and_then(text),
954
+ "member_access_expression" => f.child_by_field_name("name").and_then(text),
869
955
  "member_expression" => f.child_by_field_name("property").and_then(text),
870
956
  _ => None,
871
957
  }
@@ -901,6 +987,39 @@ mod tests {
901
987
  assert_eq!(names, [("function", "f"), ("class", "K"), ("method", "m")]);
902
988
  }
903
989
 
990
+ #[test]
991
+ fn csharp_skips_interfaces_and_keeps_logic_properties() {
992
+ let src = "interface I { int Foo(int x); }\nenum E { A, B }\nclass P { public int X { get { return Compute(); } } }\nclass Q { public int X { get; set; } }\n";
993
+ let u = extract_units("p.cs", Lang::CSharp, src);
994
+ let n: Vec<_> = u.iter().map(|x| (x.name.as_str(), x.boilerplate)).collect();
995
+ assert_eq!(n, [("P", false), ("Q", true)]);
996
+ }
997
+
998
+ #[test]
999
+ fn csharp_classes_methods_and_normalization() {
1000
+ let src = "namespace N {\n public class Calc {\n public int Add(int a, int b) {\n Console.WriteLine(\"x\");\n return Helper(a) + b;\n }\n public Calc() { }\n }\n public record Dto(int Id);\n}\n";
1001
+ let u = extract_units("Calc.cs", Lang::CSharp, src);
1002
+ let names: Vec<_> = u
1003
+ .iter()
1004
+ .map(|x| (x.kind.as_str(), x.name.as_str()))
1005
+ .collect();
1006
+ assert_eq!(
1007
+ names,
1008
+ [
1009
+ ("class", "Calc"),
1010
+ ("method", "Add"),
1011
+ ("method", "constructor"),
1012
+ ("class", "Dto")
1013
+ ]
1014
+ );
1015
+ let add = &u[1];
1016
+ assert!(add.callees.contains("helper") && !add.callees.contains("writeline"));
1017
+ let renamed = "class Other {\n int Plus(int x, int y) {\n return Foo(x) + y;\n }\n}\n";
1018
+ let b = extract_units("o.cs", Lang::CSharp, renamed);
1019
+ assert_eq!(add.tokens, b[1].tokens);
1020
+ assert!(u[2].boilerplate);
1021
+ }
1022
+
904
1023
  fn toks(src: &str) -> Vec<String> {
905
1024
  extract_units("x.py", Lang::Python, src).remove(0).tokens
906
1025
  }
@@ -5,7 +5,7 @@ build-backend = "maturin"
5
5
  [project]
6
6
  name = "duplicatecode"
7
7
  dynamic = ["version"]
8
- description = "Static (LLM-free) detection of duplicate/similar code in Python and TypeScript"
8
+ description = "Static (LLM-free) detection of duplicate/similar code in Python, TypeScript and C#"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.9"