review

review patchsets using your default editor
Log | Files | Refs

coverage.odin (6506B)


      1 package check
      2 
      3 // Two things about a change are visible only against the whole
      4 // repository: whether anything refers to what it adds, and whether it
      5 // added tests where the repository keeps them. Both are counted over the
      6 // tree in memory.
      7 
      8 import "base:runtime"
      9 import "core:fmt"
     10 import "core:strings"
     11 
     12 import "../change"
     13 import "../finding"
     14 
     15 @(private = "file")
     16 unsearchable: map[string]bool
     17 @(private = "file")
     18 runtime_methods: map[string]bool
     19 
     20 @(init)
     21 init_coverage_lists :: proc "contextless" () {
     22 	context = runtime.default_context()
     23 	// The names the language calls rather than the code, and names too
     24 	// short to search for.
     25 	unsearchable = set(`main init TestMain _ default`)
     26 	// The methods the standard library and its encoders call through an
     27 	// interface or by reflection, so that nothing in the repository names
     28 	// them and they are referenced all the same.
     29 	runtime_methods = set(
     30 		`String Error Format GoString MarshalJSON UnmarshalJSON MarshalText UnmarshalText
     31 		MarshalBinary UnmarshalBinary MarshalYAML UnmarshalYAML GobEncode GobDecode Len Less Swap Read Write
     32 		Close Seek ReadFrom WriteTo ServeHTTP Scan Value Is As Unwrap Compare Equal Hash`,
     33 	)
     34 }
     35 
     36 // check_unreferenced reports a new declaration nothing in the repository
     37 // refers to: not the change, not the rest of the tree. It is written and
     38 // waiting, and what waits drifts. The name is searched as a whole word
     39 // over every text file, so a use from a template or a script counts. One
     40 // finding per file, naming what it declares and nothing refers to.
     41 check_unreferenced :: proc(s: Scope, out: ^[dynamic]finding.Finding) {
     42 	unused := make(map[string][dynamic]change.Symbol, context.temp_allocator)
     43 	for sym in s.c.symbols {
     44 		if is_test_file(sym.file) ||
     45 		   sym.kind == "field" ||
     46 		   unsearchable[sym.name] ||
     47 		   called_by_the_runtime(sym) {
     48 			continue
     49 		}
     50 		if referenced(sym, s.sources) {
     51 			continue
     52 		}
     53 		list := unused[sym.file]
     54 		if list.allocator.procedure == nil {
     55 			list = make([dynamic]change.Symbol, context.temp_allocator)
     56 		}
     57 		append(&list, sym)
     58 		unused[sym.file] = list
     59 	}
     60 	for file in sorted_keys(unused) {
     61 		symbols := unused[file]
     62 		names := make([dynamic]string, context.temp_allocator)
     63 		for sym, i in symbols {
     64 			if i == 8 {
     65 				append(&names, fmt.tprintf("and %d more", len(symbols) - 8))
     66 				break
     67 			}
     68 			append(&names, sym.name)
     69 		}
     70 		append(
     71 			out,
     72 			static(
     73 				"new-symbol-unreferenced",
     74 				.Consider,
     75 				fmt.aprintf(
     76 					"nothing in the repository refers to %s but the declaration%s in %s; code written for a caller that does not exist yet is a guess about what the caller will need",
     77 					strings.join(names[:], ", ", context.temp_allocator),
     78 					plural(len(symbols)),
     79 					file,
     80 				),
     81 				"use it, or leave it out until something does",
     82 				file = file,
     83 				line = symbols[0].line,
     84 				symbol = symbols[0].name,
     85 			),
     86 		)
     87 	}
     88 }
     89 
     90 // called_by_the_runtime is whether a symbol is called by something
     91 // outside the repository's text: a method an encoder or interface reaches
     92 // for, or a function cgo exports to the host under //export.
     93 called_by_the_runtime :: proc(sym: change.Symbol) -> bool {
     94 	if sym.kind == "func" && runtime_methods[sym.name] {
     95 		return true
     96 	}
     97 	if strings.has_suffix(sym.file, ".go") {
     98 		rest := sym.doc
     99 		for line in strings.split_lines_iterator(&rest) {
    100 			if strings.trim_space(line) ==
    101 			   strings.concatenate({"export ", sym.name}, context.temp_allocator) {
    102 				return true
    103 			}
    104 		}
    105 	}
    106 	return false
    107 }
    108 
    109 // referenced is whether the name appears as a whole word anywhere but on
    110 // its own declaration line or in a comment. A Go doc comment opens with
    111 // the name it documents, and a comment is not a caller.
    112 referenced :: proc(sym: change.Symbol, sources: map[string][]byte) -> bool {
    113 	if len(sym.name) < 2 {
    114 		return true // Too short to search for honestly.
    115 	}
    116 	for file, data in sources {
    117 		text := string(data)
    118 		offset := 0
    119 		for {
    120 			i := strings.index(text[offset:], sym.name)
    121 			if i < 0 {
    122 				break
    123 			}
    124 			at := offset + i
    125 			offset = at + len(sym.name)
    126 			if !word_boundary(text, at, len(sym.name)) {
    127 				continue
    128 			}
    129 			if file == sym.file && line_of(text, at) == sym.line {
    130 				continue
    131 			}
    132 			if in_comment(text, at) {
    133 				continue
    134 			}
    135 			return true
    136 		}
    137 	}
    138 	return false
    139 }
    140 
    141 // word_boundary is whether the match at i of length n is bounded by
    142 // non-identifier characters on both sides.
    143 word_boundary :: proc(text: string, i, n: int) -> bool {
    144 	before := i == 0 || !ident_char(text[i - 1])
    145 	after := i + n >= len(text) || !ident_char(text[i + n])
    146 	return before && after
    147 }
    148 
    149 ident_char :: proc(b: byte) -> bool {
    150 	return b == '_' || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
    151 }
    152 
    153 // in_comment is whether the offset sits on a line that is a comment by
    154 // the shapes the tool's languages share, or after a line comment's
    155 // opening.
    156 in_comment :: proc(text: string, offset: int) -> bool {
    157 	start := strings.last_index_byte(text[:offset], '\n') + 1
    158 	line := text[start:offset]
    159 	head := strings.concatenate(
    160 		{line, text[offset:min(offset + 1, len(text))]},
    161 		context.temp_allocator,
    162 	)
    163 	if change.is_comment_line(strings.trim_space(head)) {
    164 		return true
    165 	}
    166 	return strings.contains(line, "//") || strings.contains(line, "/*")
    167 }
    168 
    169 // line_of is the 1-based line the offset falls on.
    170 line_of :: proc(text: string, offset: int) -> int {
    171 	return 1 + strings.count(text[:offset], "\n")
    172 }
    173 
    174 // test_floor_lines is the size of change, in lines added to code that is
    175 // not a test, from which a change owes a test where the repository keeps
    176 // them.
    177 test_floor_lines :: 50
    178 
    179 // check_code_without_tests reports a change that adds a body of code to a
    180 // repository that has tests, and touches none of them.
    181 check_code_without_tests :: proc(s: Scope, out: ^[dynamic]finding.Finding) {
    182 	lines := 0
    183 	for file, l in s.c.added {
    184 		if change.is_code_file(file) && !is_test_file(file) {
    185 			lines += len(l)
    186 		}
    187 	}
    188 	if lines < test_floor_lines {
    189 		return
    190 	}
    191 	for f in s.c.files {
    192 		if is_test_file(f) {
    193 			return
    194 		}
    195 	}
    196 	tested := 0
    197 	for f in s.files {
    198 		if is_test_file(f) {
    199 			tested += 1
    200 		}
    201 	}
    202 	if tested == 0 {
    203 		return // A repository without tests is not asked to start here.
    204 	}
    205 	append(
    206 		out,
    207 		static(
    208 			"code-without-tests",
    209 			.Consider,
    210 			fmt.aprintf(
    211 				"the change adds %d lines of code and touches no test, in a repository that keeps %d test files; what the change does is asserted nowhere",
    212 				lines,
    213 				tested,
    214 			),
    215 			"add or extend the test that would fail without this change",
    216 		),
    217 	)
    218 }