clones.odin (11240B)
1 package check 2 3 // Two functions with the same body are one fact stated twice, and telling 4 // that two bodies are the same needs no judgement: the tokens either 5 // match or they do not. Whether two different bodies mean the same thing 6 // is the duplication job's. 7 8 import "base:runtime" 9 import "core:fmt" 10 import "core:strings" 11 12 import "../finding" 13 14 // The floors under which two bodies are not compared. Two one-line 15 // accessors are alike because accessors are alike; a match in shape alone 16 // needs a body twice as long, because two small wrappers share a shape 17 // because wrappers do. 18 min_clone_tokens :: 40 19 min_shape_tokens :: 80 20 21 @(private = "file") 22 keywords: map[string]map[string]bool 23 24 @(init) 25 init_keywords :: proc "contextless" () { 26 context = runtime.default_context() 27 // The words a language reserves, per grammar, which a structural 28 // comparison keeps while it replaces every other identifier. 29 keywords = make(map[string]map[string]bool, runtime.heap_allocator()) 30 keywords["go"] = set( 31 `break case chan const continue default defer else fallthrough for func go goto if import 32 interface map package range return select struct switch type var nil true false iota 33 len cap append make new panic recover error string int int8 int16 int32 int64 uint uint8 uint16 34 uint32 uint64 byte rune float32 float64 bool any`, 35 ) 36 keywords["js"] = set( 37 `break case catch class const continue debugger default delete do else enum export extends 38 false finally for function if import in instanceof new null return super switch this throw true 39 try typeof var void while with yield let static async await of undefined interface type 40 implements private public protected readonly declare namespace abstract as is keyof never 41 unknown string number boolean object symbol bigint`, 42 ) 43 keywords["py"] = set( 44 `False None True and as assert async await break class continue def del elif else except 45 finally for from global if import in is lambda nonlocal not or pass raise return try while with 46 yield self cls print len range str int float list dict set tuple bool`, 47 ) 48 keywords["rs"] = set( 49 `as async await break const continue crate dyn else enum extern false fn for if impl in let 50 loop match mod move mut pub ref return self Self static struct super trait true type unsafe use 51 where while Some None Ok Err Vec String Option Result Box i8 i16 i32 i64 u8 u16 u32 u64 usize 52 isize f32 f64 bool char str`, 53 ) 54 keywords["odin"] = set( 55 `package import foreign proc struct union enum bit_set map dynamic using if else when for 56 switch case in not_in defer return break continue fallthrough cast transmute auto_cast distinct 57 matrix or_else or_return or_break or_continue where do context true false nil int uint bool 58 string rune byte f32 f64 i8 i16 i32 i64 u8 u16 u32 u64 uintptr rawptr any typeid`, 59 ) 60 } 61 62 // grammar_for is which keyword set a file's tokens are read with. 63 grammar_for :: proc(path: string) -> string { 64 switch { 65 case strings.has_suffix(path, ".go"): 66 return "go" 67 case strings.has_suffix(path, ".odin"): 68 return "odin" 69 case strings.has_suffix(path, ".py"): 70 return "py" 71 case strings.has_suffix(path, ".rs"): 72 return "rs" 73 case grammar_of(path) != "": 74 return "js" 75 } 76 if dot := strings.last_index_byte(path, '.'); dot >= 0 { 77 return path[dot:] 78 } 79 return "" 80 } 81 82 // Shapes are a body's two normalisations: exact, with only the function's 83 // own name replaced, and structural, with every identifier and literal 84 // replaced. Empty when the body is too small to compare. 85 Shapes :: struct { 86 exact: string, 87 structural: string, 88 tokens: int, 89 } 90 91 // normalise reads a body into its shapes. Whitespace and comments are 92 // gone in both; the exact shape keeps every name but the function's own, 93 // the structural shape keeps only the grammar's keywords and punctuation. 94 normalise :: proc(body, name, grammar: string, allocator := context.allocator) -> Shapes { 95 toks := lexemes(strip_comments(body, context.temp_allocator), context.temp_allocator) 96 if len(toks) < min_clone_tokens { 97 return Shapes{} 98 } 99 reserved := keywords[grammar] 100 exact := make([]string, len(toks), context.temp_allocator) 101 structural := make([]string, len(toks), context.temp_allocator) 102 for t, i in toks { 103 exact[i] = "NAME" if t == name else t 104 c := t[0] 105 switch { 106 case reserved[t]: 107 structural[i] = t 108 case c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'): 109 structural[i] = "ID" 110 case c == '"' || c == '\'' || c == '`' || (c >= '0' && c <= '9'): 111 structural[i] = "LIT" 112 case: 113 structural[i] = t 114 } 115 } 116 return Shapes { 117 exact = strings.join(exact, " ", allocator), 118 structural = strings.join(structural, " ", allocator), 119 tokens = len(toks), 120 } 121 } 122 123 // strip_comments blanks what a tokeniser drops: line comments, block 124 // comments, and the hash comments of the languages that write them at a 125 // line's start. 126 strip_comments :: proc(body: string, allocator := context.allocator) -> string { 127 b := strings.builder_make(allocator) 128 i := 0 129 line_start := true 130 for i < len(body) { 131 c := body[i] 132 switch { 133 case c == '/' && i + 1 < len(body) && body[i + 1] == '/': 134 for i < len(body) && body[i] != '\n' { 135 i += 1 136 } 137 strings.write_byte(&b, ' ') 138 continue 139 case c == '/' && i + 1 < len(body) && body[i + 1] == '*': 140 end := strings.index(body[i + 2:], "*/") 141 i = len(body) if end < 0 else i + 2 + end + 2 142 strings.write_byte(&b, ' ') 143 continue 144 case c == '#' && line_start: 145 for i < len(body) && body[i] != '\n' { 146 i += 1 147 } 148 strings.write_byte(&b, ' ') 149 continue 150 case c == '"' || c == '\'' || c == '`': 151 // A string is copied whole, so a comment marker inside it is 152 // not a comment. 153 j := i + 1 154 for j < len(body) && body[j] != c { 155 if body[j] == '\\' && c != '`' { 156 j += 1 157 } 158 if body[j] == '\n' && c != '`' { 159 break 160 } 161 j += 1 162 } 163 j = min(j + 1, len(body)) 164 strings.write_string(&b, body[i:j]) 165 i = j 166 line_start = false 167 continue 168 } 169 strings.write_byte(&b, c) 170 if c == '\n' { 171 line_start = true 172 } else if c != ' ' && c != '\t' { 173 line_start = false 174 } 175 i += 1 176 } 177 return strings.to_string(b) 178 } 179 180 // lexemes reads the lexical tokens of the C-family languages: an 181 // identifier, a number, a string in any of three quotings, a 182 // two-character operator, or one character of punctuation. 183 lexemes :: proc(text: string, allocator := context.allocator) -> []string { 184 out := make([dynamic]string, allocator) 185 i := 0 186 for i < len(text) { 187 c := text[i] 188 switch { 189 case c == ' ' || c == '\t' || c == '\n' || c == '\r': 190 i += 1 191 case c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'): 192 j := i + 1 193 for j < len(text) && 194 (text[j] == '_' || 195 (text[j] >= 'a' && text[j] <= 'z') || 196 (text[j] >= 'A' && text[j] <= 'Z') || 197 (text[j] >= '0' && text[j] <= '9')) { 198 j += 1 199 } 200 append(&out, text[i:j]) 201 i = j 202 case c >= '0' && c <= '9': 203 j := i + 1 204 if c == '0' && j < len(text) && (text[j] == 'x' || text[j] == 'X') { 205 j += 1 206 for j < len(text) && is_hex(text[j]) { 207 j += 1 208 } 209 } else { 210 for j < len(text) && text[j] >= '0' && text[j] <= '9' { 211 j += 1 212 } 213 if j + 1 < len(text) && 214 text[j] == '.' && 215 text[j + 1] >= '0' && 216 text[j + 1] <= '9' { 217 j += 1 218 for j < len(text) && text[j] >= '0' && text[j] <= '9' { 219 j += 1 220 } 221 } 222 } 223 append(&out, text[i:j]) 224 i = j 225 case c == '"' || c == '\'' || c == '`': 226 j := i + 1 227 for j < len(text) && text[j] != c { 228 if c != '`' && text[j] == '\\' { 229 j += 1 230 } else if c != '`' && text[j] == '\n' { 231 break 232 } 233 j += 1 234 } 235 if j < len(text) && text[j] == c { 236 append(&out, text[i:j + 1]) 237 i = j + 1 238 } else { 239 append(&out, text[i:i + 1]) 240 i += 1 241 } 242 case: 243 if i + 1 < len(text) && is_double_operator(text[i:i + 2]) { 244 append(&out, text[i:i + 2]) 245 i += 2 246 } else { 247 append(&out, text[i:i + 1]) 248 i += 1 249 } 250 } 251 } 252 return out[:] 253 } 254 255 is_hex :: proc(c: byte) -> bool { 256 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') 257 } 258 259 is_double_operator :: proc(s: string) -> bool { 260 switch s { 261 case ":=", 262 "::", 263 "==", 264 "!=", 265 "<=", 266 ">=", 267 "&&", 268 "||", 269 "++", 270 "--", 271 "+=", 272 "-=", 273 "*=", 274 "/=", 275 "->", 276 "=>", 277 "<<", 278 ">>": 279 return true 280 } 281 return false 282 } 283 284 // Owned is one function with its shapes, and where it is declared. 285 Owned :: struct { 286 name: string, 287 file: string, 288 line: int, 289 shape: Shapes, 290 } 291 292 // check_clones reports a new function whose body already exists: in the 293 // repository's index, or in another function the same change adds. An 294 // exact match is one function written twice and is must-fix; a match in 295 // shape alone, every name changed, is the same procedure over other 296 // names, and is worth considering. 297 check_clones :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 298 fresh := make([dynamic]Owned, context.temp_allocator) 299 for sym in s.c.symbols { 300 if sym.kind != "func" || sym.body == "" || is_test_file(sym.file) { 301 continue 302 } 303 shape := normalise(sym.body, sym.name, grammar_for(sym.file), context.temp_allocator) 304 if shape.tokens == 0 { 305 continue 306 } 307 append(&fresh, Owned{sym.name, sym.file, sym.line, shape}) 308 } 309 if len(fresh) == 0 { 310 return 311 } 312 existing := make([dynamic]Owned, context.temp_allocator) 313 for d in s.c.index { 314 if d.kind != "func" || d.body == "" { 315 continue 316 } 317 shape := normalise(d.body, d.name, grammar_for(d.file), context.temp_allocator) 318 if shape.tokens == 0 { 319 continue 320 } 321 append(&existing, Owned{d.name, d.file, d.line, shape}) 322 } 323 reported := make(map[string]bool, context.temp_allocator) 324 for a in fresh { 325 for b in existing { 326 if a.file == b.file && a.line == b.line { 327 continue 328 } 329 alike(a, b, &reported, out) 330 } 331 } 332 // Two new functions alike are reported once, the later against the 333 // earlier, where the index did not already hold the earlier. 334 for a, i in fresh { 335 for b in fresh[:i] { 336 alike(a, b, &reported, out) 337 } 338 } 339 } 340 341 // alike reports a against b when their bodies match: exactly at any size 342 // compared, or in shape when both are long enough for a shape to mean 343 // something. A pair is reported once. 344 alike :: proc(a, b: Owned, reported: ^map[string]bool, out: ^[dynamic]finding.Finding) { 345 exact := false 346 switch { 347 case a.shape.exact == b.shape.exact: 348 exact = true 349 case a.shape.tokens >= min_shape_tokens && 350 b.shape.tokens >= min_shape_tokens && 351 a.shape.structural == b.shape.structural: 352 case: 353 return 354 } 355 key := fmt.tprintf("%s:%d|%s:%d", a.file, a.line, b.file, b.line) 356 if reported[key] { 357 return 358 } 359 reported[strings.clone(key, context.temp_allocator)] = true 360 how := 361 "the same body, token for token," if exact else "the same shape of body, every name changed," 362 append( 363 out, 364 static( 365 "duplicate-body", 366 .Must_Fix if exact else .Consider, 367 fmt.aprintf( 368 "%s has %s as %s at %s:%d; one procedure written twice drifts into two", 369 a.name, 370 how, 371 b.name, 372 b.file, 373 b.line, 374 ), 375 fmt.aprintf("call %s, or lift what they share into one function both call", b.name), 376 file = a.file, 377 line = a.line, 378 symbol = a.name, 379 ), 380 ) 381 }