review

review patchsets using your default editor
Log | Files | Refs

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 }